diff --git a/.eslintrc b/.eslintrc index e40502acd6c..cb45ce5f692 100644 --- a/.eslintrc +++ b/.eslintrc @@ -147,11 +147,14 @@ "Chart": true, "Cypress": true, "cy": true, + "describe": true, + "expect": true, "it": true, "context": true, "before": true, "beforeEach": true, "onScan": true, - "extend_cscript": true + "extend_cscript": true, + "localforage": true, } } diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index c820d233a5e..e7fa354a2e9 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -10,3 +10,6 @@ # Replace use of Class.extend with native JS class 1fe891b287a1b3f225d29ee3d07e7b1824aba9e7 + +# This commit just changes spaces to tabs for indentation in some files +5f473611bd6ed57703716244a054d3fb5ba9cd23 diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 7b0f944c669..455ab861f93 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -42,5 +42,6 @@ sed -i 's/socketio:/# socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile bench get-app erpnext "${GITHUB_WORKSPACE}" -bench start & +bench start &> bench_run_logs.txt & bench --site test_site reinstall --yes +bench build --app frappe diff --git a/.github/helper/semgrep_rules/frappe_correctness.yml b/.github/helper/semgrep_rules/frappe_correctness.yml index faab3344a62..d9603e89aa4 100644 --- a/.github/helper/semgrep_rules/frappe_correctness.yml +++ b/.github/helper/semgrep_rules/frappe_correctness.yml @@ -98,8 +98,6 @@ rules: languages: [python] severity: WARNING paths: - exclude: - - test_*.py include: - "*/**/doctype/*" diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml index 5a5098bf506..8b219792080 100644 --- a/.github/helper/semgrep_rules/security.yml +++ b/.github/helper/semgrep_rules/security.yml @@ -8,18 +8,3 @@ rules: dynamic content. Avoid it or use safe_eval(). languages: [python] severity: ERROR - -- id: frappe-sqli-format-strings - patterns: - - pattern-inside: | - @frappe.whitelist() - def $FUNC(...): - ... - - pattern-either: - - pattern: frappe.db.sql("..." % ...) - - pattern: frappe.db.sql(f"...", ...) - - pattern: frappe.db.sql("...".format(...), ...) - message: | - Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines - languages: [python] - severity: WARNING diff --git a/.github/stale.yml b/.github/stale.yml index dabc66eb730..9322ae87bfc 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,11 +1,11 @@ # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale -daysUntilStale: 30 +daysUntilStale: 15 # Number of days of inactivity before a stale Issue or Pull Request is closed. # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. -daysUntilClose: 7 +daysUntilClose: 3 # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 7c6b8432b87..cc98f4544f8 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -1,16 +1,25 @@ name: Backport on: - pull_request: + pull_request_target: types: - closed - labeled jobs: - backport: - runs-on: ubuntu-18.04 - name: Backport + main: + runs-on: ubuntu-latest steps: - - name: Backport - uses: tibdex/backport@v1 + - name: Checkout Actions + uses: actions/checkout@v2 with: - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + repository: "ankush/backport" + path: ./actions + ref: develop + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run backport + uses: ./actions/backport + with: + token: ${{secrets.BACKPORT_BOT_TOKEN}} + labelsToAdd: "backport" + title: "{{originalTitle}}" diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 389524e9684..e27b406df05 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -1,34 +1,18 @@ name: Semgrep on: - pull_request: - branches: - - develop - - version-13-hotfix - - version-13-pre-release + pull_request: { } + jobs: semgrep: name: Frappe Linter runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Setup python3 - uses: actions/setup-python@v2 - with: - python-version: 3.8 - - - name: Setup semgrep - run: | - python -m pip install -q semgrep - git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q - - - name: Semgrep errors - run: | - files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - [[ -d .github/helper/semgrep_rules ]] && semgrep --severity ERROR --config=.github/helper/semgrep_rules --quiet --error $files - semgrep --config="r/python.lang.correctness" --quiet --error $files - - - name: Semgrep warnings - run: | - files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - [[ -d .github/helper/semgrep_rules ]] && semgrep --severity WARNING --severity INFO --config=.github/helper/semgrep_rules --quiet $files + - uses: actions/checkout@v2 + - uses: returntocorp/semgrep-action@v1 + env: + SEMGREP_TIMEOUT: 120 + with: + config: >- + r/python.lang.correctness + .github/helper/semgrep_rules diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 00000000000..412a05b0a15 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,108 @@ +name: UI + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-18.04 + + 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.7 + + - 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 --no-lockfile + + + - name: Build Assets + run: cd ~/frappe-bench/ && bench build + + - name: UI Tests + run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + + - name: Show bench console if tests failed + if: ${{ failure() }} + run: cat ~/frappe-bench/bench_run_logs.txt diff --git a/.gitignore b/.gitignore index 63c51c49765..89f56263ffa 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__ .idea/ .vscode/ node_modules/ +.backportrc.json \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 7cf65a7a732..a4a14de1b8e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,16 +3,33 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -manufacturing/ @rohitwaghchaure @marination -accounts/ @deepeshgarg007 @nextchamp-saqib -loan_management/ @deepeshgarg007 @rohitwaghchaure -pos* @nextchamp-saqib @rohitwaghchaure -assets/ @nextchamp-saqib @deepeshgarg007 -stock/ @marination @rohitwaghchaure -buying/ @marination @deepeshgarg007 -hr/ @Anurag810 @rohitwaghchaure -projects/ @hrwX @nextchamp-saqib -support/ @hrwX @marination -healthcare/ @ruchamahabal @marination -erpnext_integrations/ @Mangesh-Khairnar @nextchamp-saqib -requirements.txt @gavindsouza +erpnext/accounts/ @nextchamp-saqib @deepeshgarg007 +erpnext/assets/ @nextchamp-saqib @deepeshgarg007 +erpnext/erpnext_integrations/ @nextchamp-saqib +erpnext/loan_management/ @nextchamp-saqib @deepeshgarg007 +erpnext/regional @nextchamp-saqib @deepeshgarg007 +erpnext/selling @nextchamp-saqib @deepeshgarg007 +erpnext/support/ @nextchamp-saqib @deepeshgarg007 +pos* @nextchamp-saqib + +erpnext/buying/ @marination @rohitwaghchaure @ankush +erpnext/e_commerce/ @marination +erpnext/maintenance/ @marination @rohitwaghchaure +erpnext/manufacturing/ @marination @rohitwaghchaure @ankush +erpnext/portal/ @marination +erpnext/quality_management/ @marination @rohitwaghchaure +erpnext/shopping_cart/ @marination +erpnext/stock/ @marination @rohitwaghchaure @ankush + +erpnext/crm/ @ruchamahabal @pateljannat +erpnext/education/ @ruchamahabal @pateljannat +erpnext/healthcare/ @ruchamahabal @pateljannat @chillaranand +erpnext/hr/ @ruchamahabal @pateljannat +erpnext/non_profit/ @ruchamahabal +erpnext/payroll @ruchamahabal @pateljannat +erpnext/projects/ @ruchamahabal @pateljannat + +erpnext/controllers @deepeshgarg007 @nextchamp-saqib @rohitwaghchaure @marination + +.github/ @surajshetty3416 @ankush +requirements.txt @gavindsouza diff --git a/cypress.json b/cypress.json new file mode 100644 index 00000000000..02b10d893f8 --- /dev/null +++ b/cypress.json @@ -0,0 +1,11 @@ +{ + "baseUrl": "http://test_site:8000/", + "projectId": "da59y9", + "adminPassword": "admin", + "defaultCommandTimeout": 20000, + "pageLoadTimeout": 15000, + "retries": { + "runMode": 2, + "openMode": 2 + } +} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 00000000000..da18d9352a1 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} \ No newline at end of file diff --git a/cypress/integration/test_customer.js b/cypress/integration/test_customer.js new file mode 100644 index 00000000000..3d6ed5d0d89 --- /dev/null +++ b/cypress/integration/test_customer.js @@ -0,0 +1,13 @@ + +context('Customer', () => { + before(() => { + cy.login(); + }); + it('Check Customer Group', () => { + cy.visit(`app/customer/`); + cy.get('.primary-action').click(); + cy.wait(500); + cy.get('.custom-actions > .btn').click(); + cy.get_field('customer_group', 'Link').should('have.value', 'All Customer Groups'); + }); +}); diff --git a/cypress/integration/test_item.js b/cypress/integration/test_item.js new file mode 100644 index 00000000000..fcb7533a225 --- /dev/null +++ b/cypress/integration/test_item.js @@ -0,0 +1,44 @@ +describe("Test Item Dashboard", () => { + before(() => { + cy.login(); + cy.visit("/app/item"); + cy.insert_doc( + "Item", + { + item_code: "e2e_test_item", + item_group: "All Item Groups", + opening_stock: 42, + valuation_rate: 100, + }, + true + ); + cy.go_to_doc("item", "e2e_test_item"); + }); + + it("should show dashboard with correct data on first load", () => { + cy.get(".stock-levels").contains("Stock Levels").should("be.visible"); + cy.get(".stock-levels").contains("e2e_test_item").should("exist"); + + // reserved and available qty + cy.get(".stock-levels .inline-graph-count") + .eq(0) + .contains("0") + .should("exist"); + cy.get(".stock-levels .inline-graph-count") + .eq(1) + .contains("42") + .should("exist"); + }); + + it("should persist on field change", () => { + cy.get('input[data-fieldname="disabled"]').check(); + cy.wait(500); + cy.get(".stock-levels").contains("Stock Levels").should("be.visible"); + cy.get(".stock-levels").should("have.length", 1); + }); + + it("should persist on reload", () => { + cy.reload(); + cy.get(".stock-levels").contains("Stock Levels").should("be.visible"); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 00000000000..07d9804a733 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,17 @@ +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +module.exports = () => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 00000000000..7ddc80ab8dd --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,31 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }); +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }); +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }); +// +// +// -- This is will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }); + +const slug = (name) => name.toLowerCase().replace(" ", "-"); + +Cypress.Commands.add("go_to_doc", (doctype, name) => { + cy.visit(`/app/${slug(doctype)}/${encodeURIComponent(name)}`); +}); diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 00000000000..72070cc81c4 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,26 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; +import '../../../frappe/cypress/support/commands' // eslint-disable-line + + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +Cypress.Cookies.defaults({ + preserve: 'sid' +}); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000000..d90ebf6856d --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "baseUrl": "../node_modules", + "types": [ + "cypress" + ] + }, + "include": [ + "**/*.*" + ] +} \ No newline at end of file diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 5808090db35..a181c2d42cb 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -5,7 +5,7 @@ import frappe from erpnext.hooks import regional_overrides from frappe.utils import getdate -__version__ = '13.4.1' +__version__ = '13.7.1' def get_default_company(user=None): '''Get default company for user''' diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py index 5e764037a74..c417a493c69 100644 --- a/erpnext/accounts/custom/address.py +++ b/erpnext/accounts/custom/address.py @@ -33,6 +33,8 @@ def get_shipping_address(company, address = None): if address and frappe.db.get_value('Dynamic Link', {'parent': address, 'link_name': company}): filters.append(["Address", "name", "=", address]) + if not address: + filters.append(["Address", "is_shipping_address", "=", 1]) address = frappe.get_all("Address", filters=filters, fields=fields) or {} diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index dd346bc2408..335e8a15ab0 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -263,6 +263,9 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): amount, base_amount = calculate_amount(doc, item, last_gl_entry, total_days, total_booking_days, account_currency) + if not amount: + return + if via_journal_entry: book_revenue_via_journal_entry(doc, credit_account, debit_account, against, amount, base_amount, end_date, project, account_currency, item.cost_center, item, deferred_process, submit_journal_entry) @@ -298,17 +301,21 @@ def process_deferred_accounting(posting_date=None): start_date = add_months(today(), -1) end_date = add_days(today(), -1) - for record_type in ('Income', 'Expense'): - doc = frappe.get_doc(dict( - doctype='Process Deferred Accounting', - posting_date=posting_date, - start_date=start_date, - end_date=end_date, - type=record_type - )) + companies = frappe.get_all('Company') - doc.insert() - doc.submit() + for company in companies: + for record_type in ('Income', 'Expense'): + doc = frappe.get_doc(dict( + doctype='Process Deferred Accounting', + company=company.name, + posting_date=posting_date, + start_date=start_date, + end_date=end_date, + type=record_type + )) + + doc.insert() + doc.submit() def make_gl_entries(doc, credit_account, debit_account, against, amount, base_amount, posting_date, project, account_currency, cost_center, item, deferred_process=None): diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 0ebf0eb541d..fac28c92397 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -19,7 +19,7 @@ class AccountingDimension(Document): def validate(self): if self.document_type in core_doctypes_list + ('Accounting Dimension', 'Project', - 'Cost Center', 'Accounting Dimension Detail', 'Company') : + 'Cost Center', 'Accounting Dimension Detail', 'Company', 'Account') : msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type) frappe.throw(msg) @@ -27,7 +27,7 @@ class AccountingDimension(Document): exists = frappe.db.get_value("Accounting Dimension", {'document_type': self.document_type}, ['name']) if exists and self.is_new(): - frappe.throw("Document Type already used as a dimension") + frappe.throw(_("Document Type already used as a dimension")) if not self.is_new(): self.validate_document_type_change() diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 781f94e203a..703e93c0757 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -18,6 +18,7 @@ "delete_linked_ledger_entries", "book_asset_depreciation_entry_automatically", "unlink_advance_payment_on_cancelation_of_order", + "post_change_gl_entries", "tax_settings_section", "determine_address_tax_category_from", "column_break_19", @@ -253,6 +254,13 @@ { "fieldname": "column_break_19", "fieldtype": "Column Break" + }, + { + "default": "1", + "description": "If enabled, ledger entries will be posted for change amount in POS transactions", + "fieldname": "post_change_gl_entries", + "fieldtype": "Check", + "label": "Create Ledger Entries for Change Amount" } ], "icon": "icon-cog", @@ -260,7 +268,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-04-30 15:25:10.381008", + "modified": "2021-06-17 20:26:03.721202", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 4d3388090dc..ac4a2d6f16d 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe +from frappe import _ from frappe.utils import cint from frappe.model.document import Document from frappe.custom.doctype.property_setter.property_setter import make_property_setter @@ -24,7 +25,7 @@ class AccountsSettings(Document): def validate_stale_days(self): if not self.allow_stale and cint(self.stale_days) <= 0: frappe.msgprint( - "Stale Days should start from 1.", title='Error', indicator='red', + _("Stale Days should start from 1."), title='Error', indicator='red', raise_exception=1) def enable_payment_schedule_in_print(self): diff --git a/erpnext/accounts/accounts b/erpnext/accounts/doctype/advance_taxes_and_charges/__init__.py similarity index 100% rename from erpnext/accounts/accounts rename to erpnext/accounts/doctype/advance_taxes_and_charges/__init__.py diff --git a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json new file mode 100644 index 00000000000..4d634994319 --- /dev/null +++ b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json @@ -0,0 +1,197 @@ +{ + "actions": [], + "creation": "2020-09-12 22:26:19.594367", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "add_deduct_tax", + "charge_type", + "row_id", + "account_head", + "col_break_1", + "description", + "included_in_paid_amount", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "section_break_8", + "rate", + "section_break_9", + "currency", + "tax_amount", + "total", + "allocated_amount", + "column_break_13", + "base_tax_amount", + "base_total", + "base_allocated_amount" + ], + "fields": [ + { + "columns": 2, + "fieldname": "charge_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "oldfieldname": "charge_type", + "oldfieldtype": "Select", + "options": "\nActual\nOn Paid Amount\nOn Previous Row Amount\nOn Previous Row Total", + "reqd": 1 + }, + { + "depends_on": "eval:[\"On Previous Row Amount\", \"On Previous Row Total\"].indexOf(doc.charge_type)!==-1", + "fieldname": "row_id", + "fieldtype": "Data", + "label": "Reference Row #", + "oldfieldname": "row_id", + "oldfieldtype": "Data" + }, + { + "columns": 2, + "fieldname": "account_head", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Account Head", + "oldfieldname": "account_head", + "oldfieldtype": "Link", + "options": "Account", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "col_break_1", + "fieldtype": "Column Break", + "width": "50%" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Small Text", + "print_width": "300px", + "reqd": 1, + "width": "300px" + }, + { + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "default": ":Company", + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "oldfieldname": "cost_center_other_charges", + "oldfieldtype": "Link", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, + { + "columns": 2, + "fieldname": "rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Rate", + "oldfieldname": "rate", + "oldfieldtype": "Currency" + }, + { + "fieldname": "section_break_9", + "fieldtype": "Section Break" + }, + { + "columns": 2, + "fieldname": "tax_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "currency" + }, + { + "columns": 2, + "fieldname": "total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Total", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fieldname": "base_tax_amount", + "fieldtype": "Currency", + "label": "Amount (Company Currency)", + "oldfieldname": "tax_amount", + "oldfieldtype": "Currency", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "base_total", + "fieldtype": "Currency", + "label": "Total (Company Currency)", + "oldfieldname": "total", + "oldfieldtype": "Currency", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "add_deduct_tax", + "fieldtype": "Select", + "label": "Add Or Deduct", + "options": "Add\nDeduct", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "included_in_paid_amount", + "fieldtype": "Check", + "label": "Considered In Paid Amount" + }, + { + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "label": "Allocated Amount", + "options": "currency" + }, + { + "fieldname": "base_allocated_amount", + "fieldtype": "Currency", + "label": "Allocated Amount (Company Currency)", + "options": "Company:company:default_currency" + }, + { + "fetch_from": "account_head.account_currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Account Currency", + "options": "Currency", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-06-09 11:46:58.373170", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Advance Taxes and Charges", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "ASC" +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py new file mode 100644 index 00000000000..597d2ccc625 --- /dev/null +++ b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class AdvanceTaxesandCharges(Document): + pass diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 059e1d31588..19041a3f73d 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -120,4 +120,4 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { plaid_success(token, response) { frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); } -}; +}; \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index 5f110e2727c..ffc9d1c4658 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -51,7 +51,7 @@ class BankStatementImport(DataImport): self.import_file, self.google_sheets_url ) - if 'Bank Account' not in json.dumps(preview): + if 'Bank Account' not in json.dumps(preview['columns']): frappe.throw(_("Please add the Bank Account column")) from frappe.core.page.background_jobs.background_jobs import get_info diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index f96f59169e8..4fd8413d838 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -13,7 +13,8 @@ from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import from frappe.utils.xlsxutils import read_xlsx_file_from_attached_file, read_xls_file_from_attached_file class ChartofAccountsImporter(Document): - pass + def validate(self): + validate_accounts(self.import_file) @frappe.whitelist() def validate_company(company): @@ -22,7 +23,7 @@ def validate_company(company): 'allow_account_creation_against_child_company']) if parent_company and (not allow_account_creation_against_child_company): - msg = _("{} is a child company. ").format(frappe.bold(company)) + msg = _("{} is a child company.").format(frappe.bold(company)) + " " msg += _("Please import accounts against parent company or enable {} in company master.").format( frappe.bold('Allow Account Creation Against Child Company')) frappe.throw(msg, title=_('Wrong Company')) @@ -56,7 +57,7 @@ def get_file(file_name): extension = extension.lstrip(".") if extension not in ('csv', 'xlsx', 'xls'): - frappe.throw("Only CSV and Excel files can be used to for importing data. Please check the file format you are trying to upload") + frappe.throw(_("Only CSV and Excel files can be used to for importing data. Please check the file format you are trying to upload")) return file_doc, extension @@ -293,7 +294,7 @@ def validate_accounts(file_name): accounts_dict = {} for account in accounts: accounts_dict.setdefault(account["account_name"], account) - if not hasattr(account, "parent_account"): + if "parent_account" not in account: msg = _("Please make sure the file you are using has 'Parent Account' column present in the header.") msg += "

" msg += _("Alternatively, you can download the template and fill your data in.") @@ -301,28 +302,27 @@ def validate_accounts(file_name): if account["parent_account"] and accounts_dict.get(account["parent_account"]): accounts_dict[account["parent_account"]]["is_group"] = 1 - message = validate_root(accounts_dict) - if message: return message - message = validate_account_types(accounts_dict) - if message: return message + validate_root(accounts_dict) + + validate_account_types(accounts_dict) return [True, len(accounts)] def validate_root(accounts): roots = [accounts[d] for d in accounts if not accounts[d].get('parent_account')] if len(roots) < 4: - return _("Number of root accounts cannot be less than 4") + frappe.throw(_("Number of root accounts cannot be less than 4")) error_messages = [] for account in roots: if not account.get("root_type") and account.get("account_name"): - error_messages.append("Please enter Root Type for account- {0}".format(account.get("account_name"))) + error_messages.append(_("Please enter Root Type for account- {0}").format(account.get("account_name"))) elif account.get("root_type") not in get_root_types() and account.get("account_name"): - error_messages.append("Root Type for {0} must be one of the Asset, Liability, Income, Expense and Equity".format(account.get("account_name"))) + error_messages.append(_("Root Type for {0} must be one of the Asset, Liability, Income, Expense and Equity").format(account.get("account_name"))) if error_messages: - return "
".join(error_messages) + frappe.throw("
".join(error_messages)) def get_root_types(): return ('Asset', 'Liability', 'Expense', 'Income', 'Equity') @@ -356,7 +356,7 @@ def validate_account_types(accounts): missing = list(set(account_types_for_ledger) - set(account_types)) if missing: - return _("Please identify/create Account (Ledger) for type - {0}").format(' , '.join(missing)) + frappe.throw(_("Please identify/create Account (Ledger) for type - {0}").format(' , '.join(missing))) account_types_for_group = ["Bank", "Cash", "Stock"] # fix logic bug @@ -364,7 +364,7 @@ def validate_account_types(accounts): missing = list(set(account_types_for_group) - set(account_groups)) if missing: - return _("Please identify/create Account (Group) for type - {0}").format(' , '.join(missing)) + frappe.throw(_("Please identify/create Account (Group) for type - {0}").format(' , '.join(missing))) def unset_existing_data(company): linked = frappe.db.sql('''select fieldname from tabDocField diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 1a6dbedf560..1ef512a4894 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -25,7 +25,7 @@ class Dunning(AccountsController): def validate_amount(self): amounts = calculate_interest_and_amount( - self.posting_date, self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days) + self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days) if self.interest_amount != amounts.get('interest_amount'): self.interest_amount = flt(amounts.get('interest_amount'), self.precision('interest_amount')) if self.dunning_amount != amounts.get('dunning_amount'): @@ -86,18 +86,18 @@ def resolve_dunning(doc, state): for reference in doc.references: if reference.reference_doctype == 'Sales Invoice' and reference.outstanding_amount <= 0: dunnings = frappe.get_list('Dunning', filters={ - 'sales_invoice': reference.reference_name, 'status': ('!=', 'Resolved')}) + 'sales_invoice': reference.reference_name, 'status': ('!=', 'Resolved')}, ignore_permissions=True) for dunning in dunnings: frappe.db.set_value("Dunning", dunning.name, "status", 'Resolved') -def calculate_interest_and_amount(posting_date, outstanding_amount, rate_of_interest, dunning_fee, overdue_days): +def calculate_interest_and_amount(outstanding_amount, rate_of_interest, dunning_fee, overdue_days): interest_amount = 0 - grand_total = 0 + grand_total = flt(outstanding_amount) + flt(dunning_fee) if rate_of_interest: interest_per_year = flt(outstanding_amount) * flt(rate_of_interest) / 100 - interest_amount = (interest_per_year * cint(overdue_days)) / 365 - grand_total = flt(outstanding_amount) + flt(interest_amount) + flt(dunning_fee) + interest_amount = (interest_per_year * cint(overdue_days)) / 365 + grand_total += flt(interest_amount) dunning_amount = flt(interest_amount) + flt(dunning_fee) return { 'interest_amount': interest_amount, diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index e2d4d82e418..7fc2e4b3060 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -16,6 +16,7 @@ class TestDunning(unittest.TestCase): @classmethod def setUpClass(self): create_dunning_type() + create_dunning_type_with_zero_interest_rate() unlink_payment_on_cancel_of_invoice() @classmethod @@ -25,11 +26,19 @@ class TestDunning(unittest.TestCase): def test_dunning(self): dunning = create_dunning() amounts = calculate_interest_and_amount( - dunning.posting_date, dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days) + dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days) self.assertEqual(round(amounts.get('interest_amount'), 2), 0.44) self.assertEqual(round(amounts.get('dunning_amount'), 2), 20.44) self.assertEqual(round(amounts.get('grand_total'), 2), 120.44) + def test_dunning_with_zero_interest_rate(self): + dunning = create_dunning_with_zero_interest_rate() + amounts = calculate_interest_and_amount( + dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days) + self.assertEqual(round(amounts.get('interest_amount'), 2), 0) + self.assertEqual(round(amounts.get('dunning_amount'), 2), 20) + self.assertEqual(round(amounts.get('grand_total'), 2), 120) + def test_gl_entries(self): dunning = create_dunning() dunning.submit() @@ -83,6 +92,27 @@ def create_dunning(): dunning.save() return dunning +def create_dunning_with_zero_interest_rate(): + posting_date = add_days(today(), -20) + due_date = add_days(today(), -15) + sales_invoice = create_sales_invoice_against_cost_center( + posting_date=posting_date, due_date=due_date, status='Overdue') + dunning_type = frappe.get_doc("Dunning Type", 'First Notice with 0% Rate of Interest') + dunning = frappe.new_doc("Dunning") + dunning.sales_invoice = sales_invoice.name + dunning.customer_name = sales_invoice.customer_name + dunning.outstanding_amount = sales_invoice.outstanding_amount + dunning.debit_to = sales_invoice.debit_to + dunning.currency = sales_invoice.currency + dunning.company = sales_invoice.company + dunning.posting_date = nowdate() + dunning.due_date = sales_invoice.due_date + dunning.dunning_type = 'First Notice with 0% Rate of Interest' + dunning.rate_of_interest = dunning_type.rate_of_interest + dunning.dunning_fee = dunning_type.dunning_fee + dunning.save() + return dunning + def create_dunning_type(): dunning_type = frappe.new_doc("Dunning Type") dunning_type.dunning_type = 'First Notice' @@ -98,3 +128,19 @@ def create_dunning_type(): } ) dunning_type.save() + +def create_dunning_type_with_zero_interest_rate(): + dunning_type = frappe.new_doc("Dunning Type") + dunning_type.dunning_type = 'First Notice with 0% Rate of Interest' + dunning_type.start_day = 10 + dunning_type.end_day = 20 + dunning_type.dunning_fee = 20 + dunning_type.rate_of_interest = 0 + dunning_type.append( + "dunning_letter_text", { + 'language': 'en', + 'body_text': 'We have still not received payment for our invoice ', + 'closing_text': 'We kindly request that you pay the outstanding amount immediately, and late fees.' + } + ) + dunning_type.save() diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 56193216a22..f2b0a8c08a6 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -27,6 +27,9 @@ class ExchangeRateRevaluation(Document): if not (self.company and self.posting_date): frappe.throw(_("Please select Company and Posting Date to getting entries")) + def on_cancel(self): + self.ignore_linked_doctypes = ('GL Entry') + @frappe.whitelist() def check_journal_entry_condition(self): total_debit = frappe.db.get_value("Journal Entry Account", { @@ -99,10 +102,12 @@ class ExchangeRateRevaluation(Document): sum(debit) - sum(credit) as balance from `tabGL Entry` where account in (%s) - group by account, party_type, party + and posting_date <= %s + and is_cancelled = 0 + group by account, NULLIF(party_type,''), NULLIF(party,'') having sum(debit) != sum(credit) order by account - """ % ', '.join(['%s']*len(accounts)), tuple(accounts), as_dict=1) + """ % (', '.join(['%s']*len(accounts)), '%s'), tuple(accounts + [self.posting_date]), as_dict=1) return account_details @@ -143,9 +148,9 @@ class ExchangeRateRevaluation(Document): "party_type": d.get("party_type"), "party": d.get("party"), "account_currency": d.get("account_currency"), - "balance": d.get("balance_in_account_currency"), - dr_or_cr: abs(d.get("balance_in_account_currency")), - "exchange_rate":d.get("new_exchange_rate"), + "balance": flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")), + dr_or_cr: flt(abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency")), + "exchange_rate": flt(d.get("new_exchange_rate"), d.precision("new_exchange_rate")), "reference_type": "Exchange Rate Revaluation", "reference_name": self.name, }) @@ -154,9 +159,9 @@ class ExchangeRateRevaluation(Document): "party_type": d.get("party_type"), "party": d.get("party"), "account_currency": d.get("account_currency"), - "balance": d.get("balance_in_account_currency"), - reverse_dr_or_cr: abs(d.get("balance_in_account_currency")), - "exchange_rate": d.get("current_exchange_rate"), + "balance": flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")), + reverse_dr_or_cr: flt(abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency")), + "exchange_rate": flt(d.get("current_exchange_rate"), d.precision("current_exchange_rate")), "reference_type": "Exchange Rate Revaluation", "reference_name": self.name }) @@ -185,9 +190,9 @@ def get_account_details(account, company, posting_date, party_type=None, party=N account_details = {} company_currency = erpnext.get_company_currency(company) - balance = get_balance_on(account, party_type=party_type, party=party, in_account_currency=False) + balance = get_balance_on(account, date=posting_date, party_type=party_type, party=party, in_account_currency=False) if balance: - balance_in_account_currency = get_balance_on(account, party_type=party_type, party=party) + balance_in_account_currency = get_balance_on(account, date=posting_date, party_type=party_type, party=party) current_exchange_rate = balance / balance_in_account_currency if balance_in_account_currency else 0 new_exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date) new_balance_in_base_currency = balance_in_account_currency * new_exchange_rate diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index 948c51364ed..11465b711e3 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -121,8 +121,7 @@ class GLEntry(Document): def check_pl_account(self): if self.is_opening=='Yes' and \ - frappe.db.get_value("Account", self.account, "report_type")=="Profit and Loss" and \ - self.voucher_type not in ['Purchase Invoice', 'Sales Invoice']: + frappe.db.get_value("Account", self.account, "report_type")=="Profit and Loss": frappe.throw(_("{0} {1}: 'Profit and Loss' type account {2} not allowed in Opening Entry") .format(self.voucher_type, self.voucher_no, self.account)) diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py index d1e5afc48ff..b73d8bfbb11 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py @@ -49,11 +49,11 @@ class InvoiceDiscounting(AccountsController): self.make_gl_entries() def on_cancel(self): - self.set_status() + self.set_status(cancel=1) self.update_sales_invoice() self.make_gl_entries() - def set_status(self, status=None): + def set_status(self, status=None, cancel=0): if status: self.status = status self.db_set("status", status) @@ -66,6 +66,9 @@ class InvoiceDiscounting(AccountsController): elif self.docstatus == 2: self.status = "Cancelled" + if cancel: + self.db_set('status', self.status, update_modified = True) + def update_sales_invoice(self): for d in self.invoices: if self.docstatus == 1: diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js index b2e86267c8f..a8c07d6bb9b 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js @@ -49,7 +49,15 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { doc: frm.doc, btn: $(btn_primary), method: "make_invoices", - freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]) + freeze: 1, + freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]), + callback: function(r) { + if (r.message.length == 1) { + frappe.msgprint(__("{0} Invoice created successfully.", [frm.doc.invoice_type])); + } else if (r.message.length < 50) { + frappe.msgprint(__("{0} Invoices created successfully.", [frm.doc.invoice_type])); + } + } }); }); diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index 29dc96e8c6f..d76d9099628 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -216,7 +216,8 @@ def start_import(invoices): return names def publish(index, total, doctype): - if total < 5: return + if total < 50: + return frappe.publish_realtime( "opening_invoice_creation_progress", dict( @@ -241,4 +242,3 @@ def get_temporary_opening_account(company=None): return accounts[0].name - diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index b80e8ada38f..439b1edbce6 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -3,8 +3,12 @@ {% include "erpnext/public/js/controllers/accounts.js" %} frappe.provide("erpnext.accounts.dimensions"); +cur_frm.cscript.tax_table = "Advance Taxes and Charges"; + frappe.ui.form.on('Payment Entry', { onload: function(frm) { + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice']; + if(frm.doc.__islocal) { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_to) frm.set_value("paid_to_account_currency", null); @@ -91,6 +95,16 @@ frappe.ui.form.on('Payment Entry', { } }); + frm.set_query("advance_tax_account", function() { + return { + filters: { + "company": frm.doc.company, + "root_type": ["in", ["Asset", "Liability"]], + "is_group": 0 + } + } + }); + frm.set_query("reference_doctype", "references", function() { if (frm.doc.party_type == "Customer") { var doctypes = ["Sales Order", "Sales Invoice", "Journal Entry", "Dunning"]; @@ -182,6 +196,8 @@ frappe.ui.form.on('Payment Entry', { frm.doc.paid_from_account_currency != frm.doc.paid_to_account_currency)); frm.toggle_display("base_paid_amount", frm.doc.paid_from_account_currency != company_currency); + frm.toggle_display("base_total_taxes_and_charges", frm.doc.total_taxes_and_charges && + (frm.doc.paid_from_account_currency != company_currency)); frm.toggle_display("base_received_amount", ( frm.doc.paid_to_account_currency != company_currency @@ -216,7 +232,7 @@ frappe.ui.form.on('Payment Entry', { var company_currency = frm.doc.company? frappe.get_doc(":Company", frm.doc.company).default_currency: ""; frm.set_currency_labels(["base_paid_amount", "base_received_amount", "base_total_allocated_amount", - "difference_amount"], company_currency); + "difference_amount", "base_paid_amount_after_tax", "base_received_amount_after_tax"], company_currency); frm.set_currency_labels(["paid_amount"], frm.doc.paid_from_account_currency); frm.set_currency_labels(["received_amount"], frm.doc.paid_to_account_currency); @@ -224,11 +240,13 @@ frappe.ui.form.on('Payment Entry', { var party_account_currency = frm.doc.payment_type=="Receive" ? frm.doc.paid_from_account_currency : frm.doc.paid_to_account_currency; - frm.set_currency_labels(["total_allocated_amount", "unallocated_amount"], party_account_currency); + frm.set_currency_labels(["total_allocated_amount", "unallocated_amount", + "total_taxes_and_charges"], party_account_currency); var currency_field = (frm.doc.payment_type=="Receive") ? "paid_from_account_currency" : "paid_to_account_currency" frm.set_df_property("total_allocated_amount", "options", currency_field); frm.set_df_property("unallocated_amount", "options", currency_field); + frm.set_df_property("total_taxes_and_charges", "options", currency_field); frm.set_df_property("party_balance", "options", currency_field); frm.set_currency_labels(["total_amount", "outstanding_amount", "allocated_amount"], @@ -364,6 +382,16 @@ frappe.ui.form.on('Payment Entry', { } }, + apply_tax_withholding_amount: function(frm) { + if (!frm.doc.apply_tax_withholding_amount) { + frm.set_value("tax_withholding_category", ''); + } else { + frappe.db.get_value('Supplier', frm.doc.party, 'tax_withholding_category', (values) => { + frm.set_value("tax_withholding_category", values.tax_withholding_category); + }); + } + }, + paid_from: function(frm) { if(frm.set_party_account_based_on_party) return; @@ -843,12 +871,12 @@ frappe.ui.form.on('Payment Entry', { if(frm.doc.payment_type == "Receive" && frm.doc.base_total_allocated_amount < frm.doc.base_received_amount + total_deductions && frm.doc.total_allocated_amount < frm.doc.paid_amount + (total_deductions / frm.doc.source_exchange_rate)) { - unallocated_amount = (frm.doc.base_received_amount + total_deductions - - frm.doc.base_total_allocated_amount) / frm.doc.source_exchange_rate; + unallocated_amount = (frm.doc.base_received_amount + total_deductions + frm.doc.base_total_taxes_and_charges + + frm.doc.base_total_allocated_amount) / frm.doc.source_exchange_rate; } else if (frm.doc.payment_type == "Pay" && frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount - total_deductions && frm.doc.total_allocated_amount < frm.doc.received_amount + (total_deductions / frm.doc.target_exchange_rate)) { - unallocated_amount = (frm.doc.base_paid_amount - (total_deductions + unallocated_amount = (frm.doc.base_paid_amount + frm.doc.base_total_taxes_and_charges - (total_deductions + frm.doc.base_total_allocated_amount)) / frm.doc.target_exchange_rate; } } @@ -874,7 +902,8 @@ frappe.ui.form.on('Payment Entry', { var total_deductions = frappe.utils.sum($.map(frm.doc.deductions || [], function(d) { return flt(d.amount) })); - frm.set_value("difference_amount", difference_amount - total_deductions); + frm.set_value("difference_amount", difference_amount - total_deductions + + frm.doc.base_total_taxes_and_charges); frm.events.hide_unhide_fields(frm); }, @@ -1002,7 +1031,266 @@ frappe.ui.form.on('Payment Entry', { } }); } - } + }, + + sales_taxes_and_charges_template: function(frm) { + frm.trigger('fetch_taxes_from_template'); + }, + + purchase_taxes_and_charges_template: function(frm) { + frm.trigger('fetch_taxes_from_template'); + }, + + fetch_taxes_from_template: function(frm) { + let master_doctype = ''; + let taxes_and_charges = ''; + + if (frm.doc.party_type == 'Supplier') { + master_doctype = 'Purchase Taxes and Charges Template'; + taxes_and_charges = frm.doc.purchase_taxes_and_charges_template; + } else if (frm.doc.party_type == 'Customer') { + master_doctype = 'Sales Taxes and Charges Template'; + taxes_and_charges = frm.doc.sales_taxes_and_charges_template; + } + + if (!taxes_and_charges) { + return; + } + + frappe.call({ + method: "erpnext.controllers.accounts_controller.get_taxes_and_charges", + args: { + "master_doctype": master_doctype, + "master_name": taxes_and_charges + }, + callback: function(r) { + if(!r.exc && r.message) { + // set taxes table + if(r.message) { + for (let tax of r.message) { + if (tax.charge_type === 'On Net Total') { + tax.charge_type = 'On Paid Amount'; + } + me.frm.add_child("taxes", tax); + } + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + } + } + } + }); + }, + + apply_taxes: function(frm) { + frm.events.initialize_taxes(frm); + frm.events.determine_exclusive_rate(frm); + frm.events.calculate_taxes(frm); + }, + + initialize_taxes: function(frm) { + $.each(frm.doc["taxes"] || [], function(i, tax) { + frm.events.validate_taxes_and_charges(tax); + frm.events.validate_inclusive_tax(tax); + tax.item_wise_tax_detail = {}; + let tax_fields = ["total", "tax_fraction_for_current_item", + "grand_total_fraction_for_current_item"]; + + if (cstr(tax.charge_type) != "Actual") { + tax_fields.push("tax_amount"); + } + + $.each(tax_fields, function(i, fieldname) { tax[fieldname] = 0.0; }); + + frm.doc.paid_amount_after_tax = frm.doc.paid_amount; + }); + }, + + validate_taxes_and_charges: function(d) { + let msg = ""; + + if (d.account_head && !d.description) { + // set description from account head + d.description = d.account_head.split(' - ').slice(0, -1).join(' - '); + } + + if (!d.charge_type && (d.row_id || d.rate || d.tax_amount)) { + msg = __("Please select Charge Type first"); + d.row_id = ""; + d.rate = d.tax_amount = 0.0; + } else if ((d.charge_type == 'Actual' || d.charge_type == 'On Net Total' || d.charge_type == 'On Paid Amount') && d.row_id) { + msg = __("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"); + d.row_id = ""; + } else if ((d.charge_type == 'On Previous Row Amount' || d.charge_type == 'On Previous Row Total') && d.row_id) { + if (d.idx == 1) { + msg = __("Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"); + d.charge_type = ''; + } else if (!d.row_id) { + msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); + d.row_id = ""; + } else if (d.row_id && d.row_id >= d.idx) { + msg = __("Cannot refer row number greater than or equal to current row number for this Charge type"); + d.row_id = ""; + } + } + if (msg) { + frappe.validated = false; + refresh_field("taxes"); + frappe.throw(msg); + } + + }, + + validate_inclusive_tax: function(tax) { + let actual_type_error = function() { + let msg = __("Actual type tax cannot be included in Item rate in row {0}", [tax.idx]) + frappe.throw(msg); + }; + + let on_previous_row_error = function(row_range) { + let msg = __("For row {0} in {1}. To include {2} in Item rate, rows {3} must also be included", + [tax.idx, __(tax.doctype), tax.charge_type, row_range]) + frappe.throw(msg); + }; + + if(cint(tax.included_in_paid_amount)) { + if(tax.charge_type == "Actual") { + // inclusive tax cannot be of type Actual + actual_type_error(); + } else if(tax.charge_type == "On Previous Row Amount" && + !cint(this.frm.doc["taxes"][tax.row_id - 1].included_in_paid_amount) + ) { + // referred row should also be an inclusive tax + on_previous_row_error(tax.row_id); + } else if(tax.charge_type == "On Previous Row Total") { + let taxes_not_included = $.map(this.frm.doc["taxes"].slice(0, tax.row_id), + function(t) { return cint(t.included_in_paid_amount) ? null : t; }); + if(taxes_not_included.length > 0) { + // all rows above this tax should be inclusive + on_previous_row_error(tax.row_id == 1 ? "1" : "1 - " + tax.row_id); + } + } + } + }, + + determine_exclusive_rate: function(frm) { + let has_inclusive_tax = false; + $.each(frm.doc["taxes"] || [], function(i, row) { + if(cint(row.included_in_paid_amount)) has_inclusive_tax = true; + }); + if(has_inclusive_tax==false) return; + + let cumulated_tax_fraction = 0.0; + $.each(frm.doc["taxes"] || [], function(i, tax) { + tax.tax_fraction_for_current_item = frm.events.get_current_tax_fraction(frm, tax); + + if(i==0) { + tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item; + } else { + tax.grand_total_fraction_for_current_item = + me.frm.doc["taxes"][i-1].grand_total_fraction_for_current_item + + tax.tax_fraction_for_current_item; + } + + cumulated_tax_fraction += tax.tax_fraction_for_current_item; + frm.doc.paid_amount_after_tax = flt(frm.doc.paid_amount/(1+cumulated_tax_fraction)) + }); + }, + + get_current_tax_fraction: function(frm, tax) { + let current_tax_fraction = 0.0; + + if(cint(tax.included_in_paid_amount)) { + let tax_rate = tax.rate; + + if(tax.charge_type == "On Paid Amount") { + current_tax_fraction = (tax_rate / 100.0); + } else if(tax.charge_type == "On Previous Row Amount") { + current_tax_fraction = (tax_rate / 100.0) * + frm.doc["taxes"][cint(tax.row_id) - 1].tax_fraction_for_current_item; + } else if(tax.charge_type == "On Previous Row Total") { + current_tax_fraction = (tax_rate / 100.0) * + frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_fraction_for_current_item; + } + } + + if(tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") { + current_tax_fraction *= -1; + } + return current_tax_fraction; + }, + + + calculate_taxes: function(frm) { + frm.doc.total_taxes_and_charges = 0.0; + frm.doc.base_total_taxes_and_charges = 0.0; + + let actual_tax_dict = {}; + + // maintain actual tax rate based on idx + $.each(frm.doc["taxes"] || [], function(i, tax) { + if (tax.charge_type == "Actual") { + actual_tax_dict[tax.idx] = flt(tax.tax_amount, precision("tax_amount", tax)); + } + }); + + $.each(me.frm.doc["taxes"] || [], function(i, tax) { + let current_tax_amount = frm.events.get_current_tax_amount(frm, tax); + + // Adjust divisional loss to the last item + if (tax.charge_type == "Actual") { + actual_tax_dict[tax.idx] -= current_tax_amount; + if (i == frm.doc["taxes"].length - 1) { + current_tax_amount += actual_tax_dict[tax.idx]; + } + } + + tax.tax_amount = current_tax_amount; + tax.base_tax_amount = tax.tax_amount * frm.doc.source_exchange_rate; + current_tax_amount *= (tax.add_deduct_tax == "Deduct") ? -1.0 : 1.0; + + if(i==0) { + tax.total = flt(frm.doc.paid_amount_after_tax + current_tax_amount, precision("total", tax)); + } else { + tax.total = flt(frm.doc["taxes"][i-1].total + current_tax_amount, precision("total", tax)); + } + + tax.base_total = tax.total * frm.doc.source_exchange_rate; + frm.doc.total_taxes_and_charges += current_tax_amount; + frm.doc.base_total_taxes_and_charges += current_tax_amount * frm.doc.source_exchange_rate; + + frm.refresh_field('taxes'); + frm.refresh_field('total_taxes_and_charges'); + frm.refresh_field('base_total_taxes_and_charges'); + }); + }, + + get_current_tax_amount: function(frm, tax) { + let tax_rate = tax.rate; + let current_tax_amount = 0.0; + + // To set row_id by default as previous row. + if(["On Previous Row Amount", "On Previous Row Total"].includes(tax.charge_type)) { + if (tax.idx === 1) { + frappe.throw( + __("Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row")); + } + } + + if(tax.charge_type == "Actual") { + current_tax_amount = flt(tax.tax_amount, precision("tax_amount", tax)) + } else if(tax.charge_type == "On Paid Amount") { + current_tax_amount = flt((tax_rate / 100.0) * frm.doc.paid_amount_after_tax); + } else if(tax.charge_type == "On Previous Row Amount") { + current_tax_amount = flt((tax_rate / 100.0) * + frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount); + + } else if(tax.charge_type == "On Previous Row Total") { + current_tax_amount = flt((tax_rate / 100.0) * + frm.doc["taxes"][cint(tax.row_id) - 1].total); + } + + return current_tax_amount; + }, }); @@ -1049,6 +1337,38 @@ frappe.ui.form.on('Payment Entry Reference', { } }) +frappe.ui.form.on('Advance Taxes and Charges', { + rate: function(frm) { + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + }, + + tax_amount : function(frm) { + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + }, + + row_id: function(frm) { + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + }, + + taxes_remove: function(frm) { + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + }, + + included_in_paid_amount: function(frm) { + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + }, + + charge_type: function(frm) { + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); + } +}) + frappe.ui.form.on('Payment Entry Deduction', { amount: function(frm) { frm.events.set_unallocated_amount(frm); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 328584a61a0..6f362c1fbb9 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -35,12 +35,16 @@ "paid_to_account_balance", "payment_amounts_section", "paid_amount", + "paid_amount_after_tax", "source_exchange_rate", "base_paid_amount", + "base_paid_amount_after_tax", "column_break_21", "received_amount", + "received_amount_after_tax", "target_exchange_rate", "base_received_amount", + "base_received_amount_after_tax", "section_break_14", "get_outstanding_invoice", "references", @@ -52,6 +56,17 @@ "unallocated_amount", "difference_amount", "write_off_difference_amount", + "taxes_and_charges_section", + "purchase_taxes_and_charges_template", + "sales_taxes_and_charges_template", + "advance_tax_account", + "column_break_55", + "apply_tax_withholding_amount", + "tax_withholding_category", + "section_break_56", + "taxes", + "base_total_taxes_and_charges", + "total_taxes_and_charges", "deductions_or_loss_section", "deductions", "transaction_references", @@ -320,6 +335,7 @@ "reqd": 1 }, { + "depends_on": "doc.received_amount", "fieldname": "base_received_amount", "fieldtype": "Currency", "label": "Received Amount (Company Currency)", @@ -584,12 +600,119 @@ "fieldname": "custom_remarks", "fieldtype": "Check", "label": "Custom Remarks" + }, + { + "depends_on": "eval:doc.apply_tax_withholding_amount", + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "label": "Tax Withholding Category", + "mandatory_depends_on": "eval:doc.apply_tax_withholding_amount", + "options": "Tax Withholding Category" + }, + { + "default": "0", + "depends_on": "eval:doc.party_type == 'Supplier'", + "fieldname": "apply_tax_withholding_amount", + "fieldtype": "Check", + "label": "Apply Tax Withholding Amount" + }, + { + "collapsible": 1, + "fieldname": "taxes_and_charges_section", + "fieldtype": "Section Break", + "label": "Taxes and Charges" + }, + { + "depends_on": "eval:doc.party_type == 'Supplier'", + "fieldname": "purchase_taxes_and_charges_template", + "fieldtype": "Link", + "label": "Taxes and Charges Template", + "options": "Purchase Taxes and Charges Template" + }, + { + "depends_on": "eval: doc.party_type == 'Customer'", + "fieldname": "sales_taxes_and_charges_template", + "fieldtype": "Link", + "label": "Taxes and Charges Template", + "options": "Sales Taxes and Charges Template" + }, + { + "depends_on": "eval: doc.party_type == 'Supplier' || doc.party_type == 'Customer'", + "fieldname": "taxes", + "fieldtype": "Table", + "label": "Advance Taxes and Charges", + "options": "Advance Taxes and Charges" + }, + { + "fieldname": "base_total_taxes_and_charges", + "fieldtype": "Currency", + "label": "Total Taxes and Charges (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "total_taxes_and_charges", + "fieldtype": "Currency", + "label": "Total Taxes and Charges", + "read_only": 1 + }, + { + "fieldname": "paid_amount_after_tax", + "fieldtype": "Currency", + "hidden": 1, + "label": "Paid Amount After Tax", + "options": "paid_from_account_currency", + "read_only": 1 + }, + { + "fieldname": "base_paid_amount_after_tax", + "fieldtype": "Currency", + "hidden": 1, + "label": "Paid Amount After Tax (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "column_break_55", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_56", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "eval:doc.apply_tax_withholding_amount", + "description": "Provisional tax account for advance tax. Taxes are parked in this account until payments are allocated to invoices", + "fieldname": "advance_tax_account", + "fieldtype": "Link", + "label": "Advance Tax Account", + "mandatory_depends_on": "eval:doc.apply_tax_withholding_amount", + "options": "Account" + }, + { + "depends_on": "eval:doc.received_amount && doc.payment_type != 'Internal Transfer'", + "fieldname": "received_amount_after_tax", + "fieldtype": "Currency", + "hidden": 1, + "label": "Received Amount After Tax", + "options": "paid_to_account_currency", + "read_only": 1 + }, + { + "depends_on": "doc.received_amount", + "fieldname": "base_received_amount_after_tax", + "fieldtype": "Currency", + "hidden": 1, + "label": "Received Amount After Tax (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-03-08 13:05:16.958866", + "modified": "2021-07-09 08:58:15.008761", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", @@ -633,4 +756,4 @@ "sort_order": "DESC", "title_field": "title", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 544d5305513..46904f7c571 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals import frappe, erpnext, json -from frappe import _, scrub, ValidationError -from frappe.utils import flt, comma_or, nowdate, getdate +from frappe import _, scrub, ValidationError, throw +from frappe.utils import flt, comma_or, nowdate, getdate, cint from erpnext.accounts.utils import get_outstanding_invoices, get_account_currency, get_balance_on from erpnext.accounts.party import get_party_account from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account @@ -15,9 +15,11 @@ from erpnext.hr.doctype.expense_claim.expense_claim import update_reimbursed_amo from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account, get_bank_account_details from erpnext.controllers.accounts_controller import AccountsController, get_supplier_block_status from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import get_party_account_based_on_invoice_discounting - +from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import get_party_tax_withholding_details from six import string_types, iteritems +from erpnext.controllers.accounts_controller import validate_taxes_and_charges + class InvalidPaymentEntry(ValidationError): pass @@ -52,6 +54,8 @@ class PaymentEntry(AccountsController): self.set_exchange_rate() self.validate_mandatory() self.validate_reference_documents() + self.set_tax_withholding() + self.apply_taxes() self.set_amounts() self.clear_unallocated_reference_document_rows() self.validate_payment_against_negative_invoice() @@ -65,7 +69,6 @@ class PaymentEntry(AccountsController): self.set_status() def on_submit(self): - self.setup_party_account_field() if self.difference_amount: frappe.throw(_("Difference Amount must be zero")) self.make_gl_entries() @@ -78,7 +81,6 @@ class PaymentEntry(AccountsController): def on_cancel(self): self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') - self.setup_party_account_field() self.make_gl_entries(cancel=1) self.update_outstanding_amounts() self.update_advance_paid() @@ -122,6 +124,11 @@ class PaymentEntry(AccountsController): if flt(d.allocated_amount) > flt(d.outstanding_amount): frappe.throw(_("Row #{0}: Allocated Amount cannot be greater than outstanding amount.").format(d.idx)) + # Check for negative outstanding invoices as well + if flt(d.allocated_amount) < 0: + if flt(d.allocated_amount) < flt(d.outstanding_amount): + frappe.throw(_("Row #{0}: Allocated Amount cannot be greater than outstanding amount.").format(d.idx)) + def delink_advance_entry_references(self): for reference in self.references: if reference.reference_doctype in ("Sales Invoice", "Purchase Invoice"): @@ -176,8 +183,15 @@ class PaymentEntry(AccountsController): d.reference_name, self.party_account_currency) for field, value in iteritems(ref_details): + if d.exchange_gain_loss: + # for cases where gain/loss is booked into invoice + # exchange_gain_loss is calculated from invoice & populated + # and row.exchange_rate is already set to payment entry's exchange rate + # refer -> `update_reference_in_payment_entry()` in utils.py + continue + if field == 'exchange_rate' or not d.get(field) or force: - d.set(field, value) + d.db_set(field, value) def validate_payment_type(self): if self.payment_type not in ("Receive", "Pay", "Internal Transfer"): @@ -307,7 +321,6 @@ class PaymentEntry(AccountsController): + "

" + _("If this is undesirable please cancel the corresponding Payment Entry."), title=_("Warning"), indicator="orange") - def validate_journal_entry(self): for d in self.get("references"): if d.allocated_amount and d.reference_doctype == "Journal Entry": @@ -386,12 +399,104 @@ class PaymentEntry(AccountsController): else: self.status = 'Draft' + self.db_set('status', self.status, update_modified = True) + + def set_tax_withholding(self): + if not self.party_type == 'Supplier': + return + + if not self.apply_tax_withholding_amount: + return + + if not self.advance_tax_account: + frappe.throw(_("Advance TDS account is mandatory for advance TDS deduction")) + + net_total = self.paid_amount + + for reference in self.get("references"): + net_total_for_tds = 0 + if reference.reference_doctype == 'Purchase Order': + net_total_for_tds += flt(frappe.db.get_value('Purchase Order', reference.reference_name, 'net_total')) + + if net_total_for_tds: + net_total = net_total_for_tds + + # Adding args as purchase invoice to get TDS amount + args = frappe._dict({ + 'company': self.company, + 'doctype': 'Purchase Invoice', + 'supplier': self.party, + 'posting_date': self.posting_date, + 'net_total': net_total + }) + + tax_withholding_details = get_party_tax_withholding_details(args, self.tax_withholding_category) + + if not tax_withholding_details: + return + + tax_withholding_details.update({ + 'add_deduct_tax': 'Add', + 'cost_center': self.cost_center or erpnext.get_default_cost_center(self.company) + }) + + accounts = [] + for d in self.taxes: + if d.account_head == tax_withholding_details.get("account_head"): + + # Preserve user updated included in paid amount + if d.included_in_paid_amount: + tax_withholding_details.update({'included_in_paid_amount': d.included_in_paid_amount}) + + d.update(tax_withholding_details) + accounts.append(d.account_head) + + if not accounts or tax_withholding_details.get("account_head") not in accounts: + self.append("taxes", tax_withholding_details) + + to_remove = [d for d in self.taxes + if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head")] + + for d in to_remove: + self.remove(d) + + def apply_taxes(self): + self.initialize_taxes() + self.determine_exclusive_rate() + self.calculate_taxes() + def set_amounts(self): + self.set_received_amount() self.set_amounts_in_company_currency() + self.set_amounts_after_tax() self.set_total_allocated_amount() self.set_unallocated_amount() self.set_difference_amount() + def set_received_amount(self): + self.base_received_amount = self.base_paid_amount + + def set_amounts_after_tax(self): + applicable_tax = 0 + base_applicable_tax = 0 + for tax in self.get('taxes'): + if not tax.included_in_paid_amount: + amount = -1 * tax.tax_amount if tax.add_deduct_tax == 'Deduct' else tax.tax_amount + base_amount = -1 * tax.base_tax_amount if tax.add_deduct_tax == 'Deduct' else tax.base_tax_amount + + applicable_tax += amount + base_applicable_tax += base_amount + + self.paid_amount_after_tax = flt(flt(self.paid_amount) + flt(applicable_tax), + self.precision("paid_amount_after_tax")) + self.base_paid_amount_after_tax = flt(flt(self.paid_amount_after_tax) * flt(self.source_exchange_rate), + self.precision("base_paid_amount_after_tax")) + + self.received_amount_after_tax = flt(flt(self.received_amount) + flt(applicable_tax), + self.precision("paid_amount_after_tax")) + self.base_received_amount_after_tax = flt(flt(self.received_amount_after_tax) * flt(self.target_exchange_rate), + self.precision("base_paid_amount_after_tax")) + def set_amounts_in_company_currency(self): self.base_paid_amount, self.base_received_amount, self.difference_amount = 0, 0, 0 if self.paid_amount: @@ -420,16 +525,19 @@ class PaymentEntry(AccountsController): self.unallocated_amount = 0 if self.party: total_deductions = sum(flt(d.amount) for d in self.get("deductions")) + included_taxes = self.get_included_taxes() if self.payment_type == "Receive" \ and self.base_total_allocated_amount < self.base_received_amount + total_deductions \ and self.total_allocated_amount < self.paid_amount + (total_deductions / self.source_exchange_rate): - self.unallocated_amount = (self.base_received_amount + total_deductions - - self.base_total_allocated_amount) / self.source_exchange_rate + self.unallocated_amount = (self.received_amount + total_deductions - + self.base_total_allocated_amount) / self.source_exchange_rate + self.unallocated_amount -= included_taxes elif self.payment_type == "Pay" \ and self.base_total_allocated_amount < (self.base_paid_amount - total_deductions) \ and self.total_allocated_amount < self.received_amount + (total_deductions / self.target_exchange_rate): - self.unallocated_amount = (self.base_paid_amount - (total_deductions + - self.base_total_allocated_amount)) / self.target_exchange_rate + self.unallocated_amount = (self.base_paid_amount - (total_deductions + + self.base_total_allocated_amount)) / self.target_exchange_rate + self.unallocated_amount -= included_taxes def set_difference_amount(self): base_unallocated_amount = flt(self.unallocated_amount) * (flt(self.source_exchange_rate) @@ -445,10 +553,22 @@ class PaymentEntry(AccountsController): self.difference_amount = self.base_paid_amount - flt(self.base_received_amount) total_deductions = sum(flt(d.amount) for d in self.get("deductions")) + included_taxes = self.get_included_taxes() - self.difference_amount = flt(self.difference_amount - total_deductions, + self.difference_amount = flt(self.difference_amount - total_deductions - included_taxes, self.precision("difference_amount")) + def get_included_taxes(self): + included_taxes = 0 + for tax in self.get('taxes'): + if tax.included_in_paid_amount: + if tax.add_deduct_tax == 'Add': + included_taxes += tax.base_tax_amount + else: + included_taxes -= tax.base_tax_amount + + return included_taxes + # Paid amount is auto allocated in the reference document by default. # Clear the reference document which doesn't have allocated amount on validate so that form can be loaded fast def clear_unallocated_reference_document_rows(self): @@ -532,6 +652,7 @@ class PaymentEntry(AccountsController): self.add_party_gl_entries(gl_entries) self.add_bank_gl_entries(gl_entries) self.add_deductions_gl_entries(gl_entries) + self.add_tax_gl_entries(gl_entries) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) @@ -571,8 +692,8 @@ class PaymentEntry(AccountsController): gl_entries.append(gle) if self.unallocated_amount: - base_unallocated_amount = base_unallocated_amount = self.unallocated_amount * \ - (self.source_exchange_rate if self.payment_type=="Receive" else self.target_exchange_rate) + exchange_rate = self.get_exchange_rate() + base_unallocated_amount = (self.unallocated_amount * exchange_rate) gle = party_gl_dict.copy() @@ -607,6 +728,51 @@ class PaymentEntry(AccountsController): }, item=self) ) + def add_tax_gl_entries(self, gl_entries): + for d in self.get('taxes'): + account_currency = get_account_currency(d.account_head) + if account_currency != self.company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency)) + + if self.payment_type in ('Pay', 'Internal Transfer'): + dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit" + against = self.party or self.paid_from + elif self.payment_type == 'Receive': + dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit" + against = self.party or self.paid_to + + payment_or_advance_account = self.get_party_account_for_taxes() + tax_amount = d.tax_amount + base_tax_amount = d.base_tax_amount + + if self.advance_tax_account: + tax_amount = -1 * tax_amount + base_tax_amount = -1 * base_tax_amount + + gl_entries.append( + self.get_gl_dict({ + "account": d.account_head, + "against": against, + dr_or_cr: tax_amount, + dr_or_cr + "_in_account_currency": base_tax_amount + if account_currency==self.company_currency + else d.tax_amount, + "cost_center": d.cost_center + }, account_currency, item=d)) + + #Intentionally use -1 to get net values in party account + if not d.included_in_paid_amount or self.advance_tax_account: + gl_entries.append( + self.get_gl_dict({ + "account": payment_or_advance_account, + "against": against, + dr_or_cr: -1 * tax_amount, + dr_or_cr + "_in_account_currency": -1 * base_tax_amount + if account_currency==self.company_currency + else d.tax_amount, + "cost_center": self.cost_center, + }, account_currency, item=d)) + def add_deductions_gl_entries(self, gl_entries): for d in self.get("deductions"): if d.amount: @@ -625,6 +791,14 @@ class PaymentEntry(AccountsController): }, item=d) ) + def get_party_account_for_taxes(self): + if self.advance_tax_account: + return self.advance_tax_account + elif self.payment_type == 'Receive': + return self.paid_to + elif self.payment_type in ('Pay', 'Internal Transfer'): + return self.paid_from + def update_advance_paid(self): if self.payment_type in ("Receive", "Pay") and self.party: for d in self.get("references"): @@ -667,10 +841,150 @@ class PaymentEntry(AccountsController): if account_details: row.update(account_details) + + if not row.get('amount'): + # if no difference amount + return self.append('deductions', row) self.set_unallocated_amount() + def get_exchange_rate(self): + return self.source_exchange_rate if self.payment_type=="Receive" else self.target_exchange_rate + + def initialize_taxes(self): + for tax in self.get("taxes"): + validate_taxes_and_charges(tax) + validate_inclusive_tax(tax, self) + + tax_fields = ["total", "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"] + + if tax.charge_type != "Actual": + tax_fields.append("tax_amount") + + for fieldname in tax_fields: + tax.set(fieldname, 0.0) + + self.paid_amount_after_tax = self.paid_amount + + def determine_exclusive_rate(self): + if not any((cint(tax.included_in_paid_amount) for tax in self.get("taxes"))): + return + + cumulated_tax_fraction = 0 + for i, tax in enumerate(self.get("taxes")): + tax.tax_fraction_for_current_item = self.get_current_tax_fraction(tax) + if i==0: + tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item + else: + tax.grand_total_fraction_for_current_item = \ + self.get("taxes")[i-1].grand_total_fraction_for_current_item \ + + tax.tax_fraction_for_current_item + + cumulated_tax_fraction += tax.tax_fraction_for_current_item + + self.paid_amount_after_tax = flt(self.paid_amount/(1+cumulated_tax_fraction)) + + def calculate_taxes(self): + self.total_taxes_and_charges = 0.0 + self.base_total_taxes_and_charges = 0.0 + + actual_tax_dict = dict([[tax.idx, flt(tax.tax_amount, tax.precision("tax_amount"))] + for tax in self.get("taxes") if tax.charge_type == "Actual"]) + + for i, tax in enumerate(self.get('taxes')): + current_tax_amount = self.get_current_tax_amount(tax) + + if tax.charge_type == "Actual": + actual_tax_dict[tax.idx] -= current_tax_amount + if i == len(self.get("taxes")) - 1: + current_tax_amount += actual_tax_dict[tax.idx] + + tax.tax_amount = current_tax_amount + tax.base_tax_amount = tax.tax_amount * self.source_exchange_rate + + if tax.add_deduct_tax == "Deduct": + current_tax_amount *= -1.0 + else: + current_tax_amount *= 1.0 + + if i == 0: + tax.total = flt(self.paid_amount_after_tax + current_tax_amount, self.precision("total", tax)) + else: + tax.total = flt(self.get('taxes')[i-1].total + current_tax_amount, self.precision("total", tax)) + + tax.base_total = tax.total * self.source_exchange_rate + + self.total_taxes_and_charges += current_tax_amount + self.base_total_taxes_and_charges += current_tax_amount * self.source_exchange_rate + + if self.get('taxes'): + self.paid_amount_after_tax = self.get('taxes')[-1].base_total + + def get_current_tax_amount(self, tax): + tax_rate = tax.rate + + # To set row_id by default as previous row. + if tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]: + if tax.idx == 1: + frappe.throw(_("Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row")) + + if not tax.row_id: + tax.row_id = tax.idx - 1 + + if tax.charge_type == "Actual": + current_tax_amount = flt(tax.tax_amount, self.precision("tax_amount", tax)) + elif tax.charge_type == "On Paid Amount": + current_tax_amount = (tax_rate / 100.0) * self.paid_amount_after_tax + elif tax.charge_type == "On Previous Row Amount": + current_tax_amount = (tax_rate / 100.0) * \ + self.get('taxes')[cint(tax.row_id) - 1].tax_amount + + elif tax.charge_type == "On Previous Row Total": + current_tax_amount = (tax_rate / 100.0) * \ + self.get('taxes')[cint(tax.row_id) - 1].total + + return current_tax_amount + + def get_current_tax_fraction(self, tax): + current_tax_fraction = 0 + + if cint(tax.included_in_paid_amount): + tax_rate = tax.rate + + if tax.charge_type == "On Paid Amount": + current_tax_fraction = tax_rate / 100.0 + elif tax.charge_type == "On Previous Row Amount": + current_tax_fraction = (tax_rate / 100.0) * \ + self.get("taxes")[cint(tax.row_id) - 1].tax_fraction_for_current_item + elif tax.charge_type == "On Previous Row Total": + current_tax_fraction = (tax_rate / 100.0) * \ + self.get("taxes")[cint(tax.row_id) - 1].grand_total_fraction_for_current_item + + if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct": + current_tax_fraction *= -1.0 + + return current_tax_fraction + +def validate_inclusive_tax(tax, doc): + def _on_previous_row_error(row_range): + throw(_("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(tax.idx, row_range)) + + if cint(getattr(tax, "included_in_paid_amount", None)): + if tax.charge_type == "Actual": + # inclusive tax cannot be of type Actual + throw(_("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format(tax.idx)) + elif tax.charge_type == "On Previous Row Amount" and \ + not cint(doc.get("taxes")[cint(tax.row_id) - 1].included_in_paid_amount): + # referred row should also be inclusive + _on_previous_row_error(tax.row_id) + elif tax.charge_type == "On Previous Row Total" and \ + not all([cint(t.included_in_paid_amount for t in doc.get("taxes")[:cint(tax.row_id) - 1])]): + # all rows about the referred tax should be inclusive + _on_previous_row_error("1 - %d" % (cint(tax.row_id),)) + elif tax.get("category") == "Valuation": + frappe.throw(_("Valuation type charges can not be marked as Inclusive")) + @frappe.whitelist() def get_outstanding_reference_documents(args): @@ -791,7 +1105,7 @@ def split_invoices_based_on_payment_terms(outstanding_invoices): outstanding_invoices.pop(idx - 1) outstanding_invoices += invoice_ref_based_on_payment_terms[idx] - + return outstanding_invoices def get_orders_to_be_billed(posting_date, party_type, party, @@ -989,6 +1303,7 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre outstanding_amount = ref_doc.get("outstanding_amount") elif reference_doctype == "Donation": total_amount = ref_doc.get("amount") + outstanding_amount = total_amount exchange_rate = 1 elif reference_doctype == "Dunning": total_amount = ref_doc.get("dunning_amount") @@ -1045,9 +1360,9 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre return frappe._dict({ "due_date": ref_doc.get("due_date"), - "total_amount": total_amount, - "outstanding_amount": outstanding_amount, - "exchange_rate": exchange_rate, + "total_amount": flt(total_amount), + "outstanding_amount": flt(outstanding_amount), + "exchange_rate": flt(exchange_rate), "bill_no": bill_no }) @@ -1235,6 +1550,13 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= }) pe.set_difference_amount() + if doc.doctype == 'Purchase Order' and doc.apply_tds: + pe.apply_tax_withholding_amount = 1 + pe.tax_withholding_category = doc.tax_withholding_category + + if not pe.advance_tax_account: + pe.advance_tax_account = frappe.db.get_value('Company', pe.company, 'unrealized_profit_loss_account') + return pe def get_bank_cash_account(doc, bank_account): @@ -1353,6 +1675,7 @@ def set_paid_amount_and_received_amount(dt, party_account_currency, bank, outsta paid_amount = received_amount * doc.get('conversion_rate', 1) if dt == "Employee Advance": paid_amount = received_amount * doc.get('exchange_rate', 1) + return paid_amount, received_amount def apply_early_payment_discount(paid_amount, received_amount, doc): diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 4641d6b5ffa..d1302f5ae78 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -589,9 +589,9 @@ class TestPaymentEntry(unittest.TestCase): party_account_balance = get_balance_on(account=pe.paid_from, cost_center=pe.cost_center) self.assertEqual(pe.cost_center, si.cost_center) - self.assertEqual(expected_account_balance, account_balance) - self.assertEqual(expected_party_balance, party_balance) - self.assertEqual(expected_party_account_balance, party_account_balance) + self.assertEqual(flt(expected_account_balance), account_balance) + self.assertEqual(flt(expected_party_balance), party_balance) + self.assertEqual(flt(expected_party_account_balance), party_account_balance) def create_payment_terms_template(): diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 7060d116913..61a1462dd7a 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -1,140 +1,70 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-06-15 15:56:30.815503", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "creation": "2016-06-15 15:56:30.815503", + "doctype": "DocType", + "editable_grid": 1, + "field_order": [ + "account", + "cost_center", + "amount", + "column_break_2", + "description" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "account", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Account", + "options": "Account", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "cost_center", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Cost Center", - "length": 0, - "no_copy": 0, - "options": "Cost Center", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "cost_center", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Cost Center", + "options": "Cost Center", + "print_hide": 1, + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Amount", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "show_days": 1, + "show_seconds": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2019-01-07 16:52:07.040146", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Payment Entry Deduction", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-09-12 20:38:08.110674", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Entry Deduction", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json index 912ad0977a2..43eb0b6e2aa 100644 --- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json +++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json @@ -14,7 +14,8 @@ "total_amount", "outstanding_amount", "allocated_amount", - "exchange_rate" + "exchange_rate", + "exchange_gain_loss" ], "fields": [ { @@ -90,12 +91,19 @@ "fieldtype": "Link", "label": "Payment Term", "options": "Payment Term" + }, + { + "fieldname": "exchange_gain_loss", + "fieldtype": "Currency", + "label": "Exchange Gain/Loss", + "options": "Company:company:default_currency", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-10 11:25:47.144392", + "modified": "2021-04-21 13:30:11.605388", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Reference", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 520e87a6ce1..438951db627 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -101,7 +101,7 @@ class PaymentRequest(Document): controller.validate_transaction_currency(self.currency) controller.request_for_payment(**payment_record) - + def get_request_amount(self): data_of_completed_requests = frappe.get_all("Integration Request", filters={ 'reference_doctype': self.doctype, @@ -492,7 +492,6 @@ def update_payment_req_status(doc, method): status = 'Requested' pay_req_doc.db_set('status', status) - frappe.db.commit() def get_dummy_message(doc): return frappe.render_template("""{% if doc.contact_person -%} diff --git a/erpnext/accounts/doctype/payment_terms_template/payment_terms_template.py b/erpnext/accounts/doctype/payment_terms_template/payment_terms_template.py index 80e3348d817..39627eb376a 100644 --- a/erpnext/accounts/doctype/payment_terms_template/payment_terms_template.py +++ b/erpnext/accounts/doctype/payment_terms_template/payment_terms_template.py @@ -26,7 +26,7 @@ class PaymentTermsTemplate(Document): def check_duplicate_terms(self): terms = [] for term in self.terms: - term_info = (term.credit_days, term.credit_months, term.due_date_based_on) + term_info = (term.payment_term, term.credit_days, term.credit_months, term.due_date_based_on) if term_info in terms: frappe.msgprint( _('The Payment Term at row {0} is possibly a duplicate.').format(term.idx), diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json index 47546c07a43..84c941ecc10 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json @@ -1,350 +1,138 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "ACC-PCV-.YYYY.-.#####", - "beta": 0, - "creation": "2013-01-10 16:34:07", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "editable_grid": 0, - "engine": "InnoDB", + "actions": [], + "autoname": "ACC-PCV-.YYYY.-.#####", + "creation": "2013-01-10 16:34:07", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "transaction_date", + "posting_date", + "fiscal_year", + "amended_from", + "company", + "cost_center_wise_pnl", + "column_break1", + "closing_account_head", + "remarks" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "transaction_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Transaction Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "transaction_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "transaction_date", + "fieldtype": "Date", + "label": "Transaction Date", + "oldfieldname": "transaction_date", + "oldfieldtype": "Date" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "posting_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Posting Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "posting_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Posting Date", + "oldfieldname": "posting_date", + "oldfieldtype": "Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "fiscal_year", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Closing Fiscal Year", - "length": 0, - "no_copy": 0, - "oldfieldname": "fiscal_year", - "oldfieldtype": "Select", - "options": "Fiscal Year", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "fiscal_year", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Closing Fiscal Year", + "oldfieldname": "fiscal_year", + "oldfieldtype": "Select", + "options": "Fiscal Year", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 1, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Amended From", - "length": 0, - "no_copy": 1, - "oldfieldname": "amended_from", - "oldfieldtype": "Data", - "options": "Period Closing Voucher", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "amended_from", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Amended From", + "no_copy": 1, + "oldfieldname": "amended_from", + "oldfieldtype": "Data", + "options": "Period Closing Voucher", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Company", - "length": 0, - "no_copy": 0, - "oldfieldname": "company", - "oldfieldtype": "Select", - "options": "Company", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "oldfieldname": "company", + "oldfieldtype": "Select", + "options": "Company", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break1", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "oldfieldtype": "Column Break", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break1", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "The account head under Liability or Equity, in which Profit/Loss will be booked", - "fieldname": "closing_account_head", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Closing Account Head", - "length": 0, - "no_copy": 0, - "oldfieldname": "closing_account_head", - "oldfieldtype": "Link", - "options": "Account", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "description": "The account head under Liability or Equity, in which Profit/Loss will be booked", + "fieldname": "closing_account_head", + "fieldtype": "Link", + "label": "Closing Account Head", + "oldfieldname": "closing_account_head", + "oldfieldtype": "Link", + "options": "Account", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "remarks", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Remarks", - "length": 0, - "no_copy": 0, - "oldfieldname": "remarks", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "remarks", + "fieldtype": "Small Text", + "label": "Remarks", + "oldfieldname": "remarks", + "oldfieldtype": "Small Text", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "cost_center_wise_pnl", + "fieldtype": "Check", + "label": "Book Cost Center Wise Profit/Loss" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-file-text", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Period Closing Voucher", - "owner": "Administrator", + ], + "icon": "fa fa-file-text", + "idx": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-05-20 15:27:37.210458", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Period Closing Voucher", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "search_fields": "posting_date, fiscal_year", - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "closing_account_head", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "search_fields": "posting_date, fiscal_year", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "closing_account_head" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index fdb4f6fc716..9cfb47876c8 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -51,63 +51,96 @@ class PeriodClosingVoucher(AccountsController): def make_gl_entries(self): gl_entries = [] - net_pl_balance = 0 - dimension_fields = ['t1.cost_center'] + net_pl_balance = 0 - accounting_dimensions = get_accounting_dimensions() - for dimension in accounting_dimensions: - dimension_fields.append('t1.{0}'.format(dimension)) - - dimension_filters, default_dimensions = get_dimensions() - - pl_accounts = self.get_pl_balances(dimension_fields) + pl_accounts = self.get_pl_balances() for acc in pl_accounts: - if flt(acc.balance_in_company_currency): + if flt(acc.bal_in_company_currency): gl_entries.append(self.get_gl_dict({ "account": acc.account, "cost_center": acc.cost_center, "account_currency": acc.account_currency, - "debit_in_account_currency": abs(flt(acc.balance_in_account_currency)) \ - if flt(acc.balance_in_account_currency) < 0 else 0, - "debit": abs(flt(acc.balance_in_company_currency)) \ - if flt(acc.balance_in_company_currency) < 0 else 0, - "credit_in_account_currency": abs(flt(acc.balance_in_account_currency)) \ - if flt(acc.balance_in_account_currency) > 0 else 0, - "credit": abs(flt(acc.balance_in_company_currency)) \ - if flt(acc.balance_in_company_currency) > 0 else 0 + "debit_in_account_currency": abs(flt(acc.bal_in_account_currency)) if flt(acc.bal_in_account_currency) < 0 else 0, + "debit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) < 0 else 0, + "credit_in_account_currency": abs(flt(acc.bal_in_account_currency)) if flt(acc.bal_in_account_currency) > 0 else 0, + "credit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) > 0 else 0 }, item=acc)) - net_pl_balance += flt(acc.balance_in_company_currency) + net_pl_balance += flt(acc.bal_in_company_currency) if net_pl_balance: - cost_center = frappe.db.get_value("Company", self.company, "cost_center") - gl_entry = self.get_gl_dict({ - "account": self.closing_account_head, - "debit_in_account_currency": abs(net_pl_balance) if net_pl_balance > 0 else 0, - "debit": abs(net_pl_balance) if net_pl_balance > 0 else 0, - "credit_in_account_currency": abs(net_pl_balance) if net_pl_balance < 0 else 0, - "credit": abs(net_pl_balance) if net_pl_balance < 0 else 0, - "cost_center": cost_center - }) - - for dimension in accounting_dimensions: - gl_entry.update({ - dimension: default_dimensions.get(self.company, {}).get(dimension) - }) - - gl_entries.append(gl_entry) + if self.cost_center_wise_pnl: + costcenter_wise_gl_entries = self.get_costcenter_wise_pnl_gl_entries(pl_accounts) + gl_entries += costcenter_wise_gl_entries + else: + gl_entry = self.get_pnl_gl_entry(net_pl_balance) + gl_entries.append(gl_entry) from erpnext.accounts.general_ledger import make_gl_entries make_gl_entries(gl_entries) + + def get_pnl_gl_entry(self, net_pl_balance): + cost_center = frappe.db.get_value("Company", self.company, "cost_center") + gl_entry = self.get_gl_dict({ + "account": self.closing_account_head, + "debit_in_account_currency": abs(net_pl_balance) if net_pl_balance > 0 else 0, + "debit": abs(net_pl_balance) if net_pl_balance > 0 else 0, + "credit_in_account_currency": abs(net_pl_balance) if net_pl_balance < 0 else 0, + "credit": abs(net_pl_balance) if net_pl_balance < 0 else 0, + "cost_center": cost_center + }) + + self.update_default_dimensions(gl_entry) + + return gl_entry + + def get_costcenter_wise_pnl_gl_entries(self, pl_accounts): + company_cost_center = frappe.db.get_value("Company", self.company, "cost_center") + gl_entries = [] + + for acc in pl_accounts: + if flt(acc.bal_in_company_currency): + gl_entry = self.get_gl_dict({ + "account": self.closing_account_head, + "cost_center": acc.cost_center or company_cost_center, + "account_currency": acc.account_currency, + "debit_in_account_currency": abs(flt(acc.bal_in_account_currency)) if flt(acc.bal_in_account_currency) > 0 else 0, + "debit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) > 0 else 0, + "credit_in_account_currency": abs(flt(acc.bal_in_account_currency)) if flt(acc.bal_in_account_currency) < 0 else 0, + "credit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) < 0 else 0 + }, item=acc) + + self.update_default_dimensions(gl_entry) + + gl_entries.append(gl_entry) + + return gl_entries + + def update_default_dimensions(self, gl_entry): + if not self.accounting_dimensions: + self.accounting_dimensions = get_accounting_dimensions() + + _, default_dimensions = get_dimensions() + for dimension in self.accounting_dimensions: + gl_entry.update({ + dimension: default_dimensions.get(self.company, {}).get(dimension) + }) + + def get_pl_balances(self): + """Get balance for dimension-wise pl accounts""" + + dimension_fields = ['t1.cost_center'] + + self.accounting_dimensions = get_accounting_dimensions() + for dimension in self.accounting_dimensions: + dimension_fields.append('t1.{0}'.format(dimension)) - def get_pl_balances(self, dimension_fields): - """Get balance for Profit and Loss accounts, only including valid transactions (not cancelled)""" return frappe.db.sql(""" select t1.account, t2.account_currency, {dimension_fields}, - sum(t1.debit_in_account_currency) - sum(t1.credit_in_account_currency) as balance_in_account_currency, - sum(t1.debit) - sum(t1.credit) as balance_in_company_currency + sum(t1.debit_in_account_currency) - sum(t1.credit_in_account_currency) as bal_in_account_currency, + sum(t1.debit) - sum(t1.credit) as bal_in_company_currency from `tabGL Entry` t1, `tabAccount` t2 where t1.is_cancelled = 0 and t1.account = t2.name and t2.report_type = 'Profit and Loss' and t2.docstatus < 2 and t2.company = %s diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index eb02d97b789..2f29372b01c 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -8,6 +8,7 @@ import frappe from frappe.utils import flt, today from erpnext.accounts.utils import get_fiscal_year, now from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice class TestPeriodClosingVoucher(unittest.TestCase): def test_closing_entry(self): @@ -65,6 +66,58 @@ class TestPeriodClosingVoucher(unittest.TestCase): self.assertEqual(gle_for_random_expense_account[0].amount_in_account_currency, -1*random_expense_account[0].balance_in_account_currency) + def test_cost_center_wise_posting(self): + frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") + + company = create_company() + surplus_account = create_account() + + cost_center1 = create_cost_center("Test Cost Center 1") + cost_center2 = create_cost_center("Test Cost Center 2") + + create_sales_invoice( + company=company, + cost_center=cost_center1, + income_account="Sales - TPC", + expense_account="Cost of Goods Sold - TPC", + rate=400, + debit_to="Debtors - TPC" + ) + create_sales_invoice( + company=company, + cost_center=cost_center2, + income_account="Sales - TPC", + expense_account="Cost of Goods Sold - TPC", + rate=200, + debit_to="Debtors - TPC" + ) + + pcv = frappe.get_doc({ + "transaction_date": today(), + "posting_date": today(), + "fiscal_year": get_fiscal_year(today())[0], + "company": "Test PCV Company", + "cost_center_wise_pnl": 1, + "closing_account_head": surplus_account, + "remarks": "Test", + "doctype": "Period Closing Voucher" + }) + pcv.insert() + pcv.submit() + + expected_gle = ( + ('Sales - TPC', 200.0, 0.0, cost_center2), + (surplus_account, 0.0, 200.0, cost_center2), + ('Sales - TPC', 400.0, 0.0, cost_center1), + (surplus_account, 0.0, 400.0, cost_center1) + ) + + pcv_gle = frappe.db.sql(""" + select account, debit, credit, cost_center from `tabGL Entry` where voucher_no=%s + """, (pcv.name)) + + self.assertTrue(pcv_gle, expected_gle) + def make_period_closing_voucher(self): pcv = frappe.get_doc({ "doctype": "Period Closing Voucher", @@ -80,6 +133,38 @@ class TestPeriodClosingVoucher(unittest.TestCase): return pcv +def create_company(): + company = frappe.get_doc({ + 'doctype': 'Company', + 'company_name': "Test PCV Company", + 'country': 'United States', + 'default_currency': 'USD' + }) + company.insert(ignore_if_duplicate = True) + return company.name + +def create_account(): + account = frappe.get_doc({ + "account_name": "Reserve and Surplus", + "is_group": 0, + "company": "Test PCV Company", + "root_type": "Liability", + "report_type": "Balance Sheet", + "account_currency": "USD", + "parent_account": "Current Liabilities - TPC", + "doctype": "Account" + }).insert(ignore_if_duplicate = True) + return account.name + +def create_cost_center(cc_name): + costcenter = frappe.get_doc({ + "company": "Test PCV Company", + "cost_center_name": cc_name, + "doctype": "Cost Center", + "parent_cost_center": "Test PCV Company - TPC" + }) + costcenter.insert(ignore_if_duplicate = True) + return costcenter.name test_dependencies = ["Customer", "Cost Center"] test_records = frappe.get_test_records("Period Closing Voucher") diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js index 8c5a34a0d8e..6418d730903 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js @@ -107,7 +107,7 @@ frappe.ui.form.on('POS Closing Entry', { frm.set_value("taxes", []); for (let row of frm.doc.payment_reconciliation) { - row.expected_amount = 0; + row.expected_amount = row.opening_amount; } for (let row of frm.doc.pos_transactions) { @@ -154,6 +154,9 @@ function add_to_pos_transaction(d, frm) { function refresh_payments(d, frm) { d.payments.forEach(p => { const payment = frm.doc.payment_reconciliation.find(pay => pay.mode_of_payment === p.mode_of_payment); + if (p.account == d.account_for_change_amount) { + p.amount -= flt(d.change_amount); + } if (payment) { payment.expected_amount += flt(p.amount); payment.difference = payment.closing_amount - payment.expected_amount; diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index f55fdab21c3..8ec4ef224cd 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -140,6 +140,7 @@ class POSInvoice(SalesInvoice): return available_stock = get_stock_availability(d.item_code, d.warehouse) + item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty) if flt(available_stock) <= 0: frappe.throw(_('Row #{}: Item Code: {} is not available under warehouse {}.') @@ -213,8 +214,9 @@ class POSInvoice(SalesInvoice): for d in self.get("items"): is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item") if not is_stock_item: - frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice. ") - .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item")) + if not frappe.db.exists('Product Bundle', d.item_code): + frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.") + .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item")) def validate_mode_of_payment(self): if len(self.payments) == 0: @@ -455,15 +457,36 @@ class POSInvoice(SalesInvoice): @frappe.whitelist() def get_stock_availability(item_code, warehouse): + if frappe.db.get_value('Item', item_code, 'is_stock_item'): + bin_qty = get_bin_qty(item_code, warehouse) + pos_sales_qty = get_pos_reserved_qty(item_code, warehouse) + return bin_qty - pos_sales_qty + else: + if frappe.db.exists('Product Bundle', item_code): + return get_bundle_availability(item_code, warehouse) + +def get_bundle_availability(bundle_item_code, warehouse): + product_bundle = frappe.get_doc('Product Bundle', bundle_item_code) + + bundle_bin_qty = 1000000 + for item in product_bundle.items: + item_bin_qty = get_bin_qty(item.item_code, warehouse) + item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) + available_qty = item_bin_qty - item_pos_reserved_qty + + max_available_bundles = available_qty / item.qty + if bundle_bin_qty > max_available_bundles: + bundle_bin_qty = max_available_bundles + + pos_sales_qty = get_pos_reserved_qty(bundle_item_code, warehouse) + return bundle_bin_qty - pos_sales_qty + +def get_bin_qty(item_code, warehouse): bin_qty = frappe.db.sql("""select actual_qty from `tabBin` where item_code = %s and warehouse = %s limit 1""", (item_code, warehouse), as_dict=1) - pos_sales_qty = get_pos_reserved_qty(item_code, warehouse) - - bin_qty = bin_qty[0].actual_qty or 0 if bin_qty else 0 - - return bin_qty - pos_sales_qty + return bin_qty[0].actual_qty or 0 if bin_qty else 0 def get_pos_reserved_qty(item_code, warehouse): reserved_qty = frappe.db.sql("""select sum(p_item.qty) as qty @@ -522,4 +545,4 @@ def add_return_modes(doc, pos_profile): mode_of_payment = pos_payment_method.mode_of_payment if pos_payment_method.allow_in_returns and not [d for d in doc.get('payments') if d.mode_of_payment == mode_of_payment]: payment_mode = get_mode_of_payment_info(mode_of_payment, doc.company) - append_payment(payment_mode[0]) \ No newline at end of file + append_payment(payment_mode[0]) 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 6dc46430e06..088c190f451 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 @@ -19,7 +19,7 @@ frappe.ui.form.on('Process Statement Of Accounts', { frappe.show_alert({message: __('Emails Queued'), indicator: 'blue'}); } else{ - frappe.msgprint('No Records for these settings.') + frappe.msgprint(__('No Records for these settings.')) } } }); @@ -33,7 +33,7 @@ frappe.ui.form.on('Process Statement Of Accounts', { type: 'GET', success: function(result) { if(jQuery.isEmptyObject(result)){ - frappe.msgprint('No Records for these settings.'); + frappe.msgprint(__('No Records for these settings.')); } else{ window.location = url; @@ -92,7 +92,7 @@ frappe.ui.form.on('Process Statement Of Accounts', { frm.refresh_field('customers'); } else{ - frappe.throw('No Customers found with selected options.'); + frappe.throw(__('No Customers found with selected options.')); } } } @@ -129,4 +129,4 @@ frappe.ui.form.on('Process Statement Of Accounts Customer', { } }) } -}); \ No newline at end of file +}); 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 0b0ee904ff9..500952e38ad 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 @@ -207,10 +207,9 @@ def fetch_customers(customer_collection, collection_name, primary_mandatory): @frappe.whitelist() def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=True): billing_email = frappe.db.sql(""" - SELECT c.email_id FROM `tabContact` AS c JOIN `tabDynamic Link` AS l ON c.name=l.parent \ - WHERE l.link_doctype='Customer' and l.link_name='""" + customer_name + """' and \ - c.is_billing_contact=1 \ - order by c.creation desc""") + SELECT c.email_id FROM `tabContact` AS c JOIN `tabDynamic Link` AS l ON c.name=l.parent + WHERE l.link_doctype='Customer' and l.link_name=%s and c.is_billing_contact=1 + order by c.creation desc""", customer_name) if len(billing_email) == 0 or (billing_email[0][0] is None): if billing_and_primary: diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 617b5b49d4c..7562418fd2f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -27,10 +27,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. }); } - company() { - erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); - } - onload() { super.onload(); @@ -569,5 +565,9 @@ frappe.ui.form.on("Purchase Invoice", { frm: frm, freeze_message: __("Creating Purchase Receipt ...") }) - } + }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, }) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index d3d3ffa17fa..00ef7d5c184 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -592,11 +592,12 @@ "label": "Raw Materials Supplied" }, { + "depends_on": "update_stock", "fieldname": "supplied_items", "fieldtype": "Table", "label": "Supplied Items", - "options": "Purchase Receipt Item Supplied", - "read_only": 1 + "no_copy": 1, + "options": "Purchase Receipt Item Supplied" }, { "fieldname": "section_break_26", @@ -837,6 +838,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment (Company Currency)", @@ -883,6 +885,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment", @@ -1380,7 +1383,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2021-04-30 22:45:58.334107", + "modified": "2021-06-15 18:20:56.806195", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 83e9f7583e9..b99d75ec496 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -68,9 +68,6 @@ class PurchaseInvoice(BuyingController): super(PurchaseInvoice, self).validate() - # apply tax withholding only if checked and applicable - self.set_tax_withholding() - if not self.is_return: self.po_required() self.pr_required() @@ -251,11 +248,9 @@ class PurchaseInvoice(BuyingController): if self.update_stock and (not item.from_warehouse): if for_validate and item.expense_account and item.expense_account != warehouse_account[item.warehouse]["account"]: - msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(warehouse_account[item.warehouse]["account"])) - msg += _("because account {} is not linked to warehouse {} ").format(frappe.bold(item.expense_account), frappe.bold(item.warehouse)) - msg += _("or it is not the default inventory account") + msg = _("Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account").format( + item.idx, frappe.bold(warehouse_account[item.warehouse]["account"]), frappe.bold(item.expense_account), frappe.bold(item.warehouse)) frappe.msgprint(msg, title=_("Expense Head Changed")) - item.expense_account = warehouse_account[item.warehouse]["account"] else: # check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not @@ -266,8 +261,8 @@ class PurchaseInvoice(BuyingController): if negative_expense_booked_in_pr: if for_validate and item.expense_account and item.expense_account != stock_not_billed_account: - msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(stock_not_billed_account)) - msg += _("because expense is booked against this account in Purchase Receipt {}").format(frappe.bold(item.purchase_receipt)) + msg = _("Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}").format( + item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.purchase_receipt)) frappe.msgprint(msg, title=_("Expense Head Changed")) item.expense_account = stock_not_billed_account @@ -275,8 +270,9 @@ class PurchaseInvoice(BuyingController): # If no purchase receipt present then book expense in 'Stock Received But Not Billed' # This is done in cases when Purchase Invoice is created before Purchase Receipt if for_validate and item.expense_account and item.expense_account != stock_not_billed_account: - msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(stock_not_billed_account)) - msg += _("as no Purchase Receipt is created against Item {}. ").format(frappe.bold(item.item_code)) + msg = _("Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}.").format( + item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code)) + msg += "
" msg += _("This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice") frappe.msgprint(msg, title=_("Expense Head Changed")) @@ -308,8 +304,8 @@ class PurchaseInvoice(BuyingController): if not d.purchase_order: msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code)) msg += "

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

" - msg += _("To submit the invoice without purchase receipt please set {} ").format(frappe.bold(_('Purchase Receipt Required'))) - msg += _("as {} in {}").format(frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings')) + msg += _("To submit the invoice without purchase receipt please set {0} as {1} in {2}").format( + frappe.bold(_('Purchase Receipt Required')), frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings')) throw(msg, title=_("Mandatory Purchase Receipt")) def validate_write_off_account(self): @@ -404,6 +400,7 @@ class PurchaseInvoice(BuyingController): # because updating ordered qty in bin depends upon updated ordered qty in PO if self.update_stock == 1: self.update_stock_ledger() + self.set_consumed_qty_in_po() from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit update_serial_nos_after_submit(self, "items") @@ -454,8 +451,11 @@ class PurchaseInvoice(BuyingController): self.get_asset_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) + self.make_exchange_gain_loss_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) + self.allocate_advance_taxes(gl_entries) + gl_entries = make_regional_gl_entries(gl_entries, self) gl_entries = merge_similar_entries(gl_entries) @@ -518,6 +518,8 @@ class PurchaseInvoice(BuyingController): if d.category in ('Valuation', 'Total and Valuation') and flt(d.base_tax_amount_after_discount_amount)] + exchange_rate_map, net_rate_map = get_purchase_document_details(self) + for item in self.get("items"): if flt(item.base_net_amount): account_currency = get_account_currency(item.expense_account) @@ -635,6 +637,34 @@ class PurchaseInvoice(BuyingController): "project": item.project or self.project }, account_currency, item=item)) + # check if the exchange rate has changed + if item.get('purchase_receipt'): + if exchange_rate_map[item.purchase_receipt] and \ + self.conversion_rate != exchange_rate_map[item.purchase_receipt] and \ + item.net_rate == net_rate_map[item.pr_detail]: + + discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * \ + (exchange_rate_map[item.purchase_receipt] - self.conversion_rate) + + gl_entries.append( + self.get_gl_dict({ + "account": expense_account, + "against": self.supplier, + "debit": discrepancy_caused_by_exchange_rate_difference, + "cost_center": item.cost_center, + "project": item.project or self.project + }, account_currency, item=item) + ) + gl_entries.append( + self.get_gl_dict({ + "account": self.get_company_default("exchange_gain_loss_account"), + "against": self.supplier, + "credit": discrepancy_caused_by_exchange_rate_difference, + "cost_center": item.cost_center, + "project": item.project or self.project + }, account_currency, item=item) + ) + # If asset is bought through this document and not linked to PR if self.update_stock and item.landed_cost_voucher_amount: expenses_included_in_asset_valuation = self.get_company_default("expenses_included_in_asset_valuation") @@ -1000,6 +1030,7 @@ class PurchaseInvoice(BuyingController): if self.update_stock == 1: self.update_stock_ledger() self.delete_auto_created_batches() + self.set_consumed_qty_in_po() self.make_gl_entries_on_cancel() @@ -1090,6 +1121,7 @@ class PurchaseInvoice(BuyingController): for d in self.taxes: if d.account_head == tax_withholding_details.get("account_head"): d.update(tax_withholding_details) + accounts.append(d.account_head) if not accounts or tax_withholding_details.get("account_head") not in accounts: @@ -1140,6 +1172,36 @@ class PurchaseInvoice(BuyingController): if update: self.db_set('status', self.status, update_modified = update_modified) +# to get details of purchase invoice/receipt from which this doc was created for exchange rate difference handling +def get_purchase_document_details(doc): + if doc.doctype == 'Purchase Invoice': + doc_reference = 'purchase_receipt' + items_reference = 'pr_detail' + parent_doctype = 'Purchase Receipt' + child_doctype = 'Purchase Receipt Item' + else: + doc_reference = 'purchase_invoice' + items_reference = 'purchase_invoice_item' + parent_doctype = 'Purchase Invoice' + child_doctype = 'Purchase Invoice Item' + + purchase_receipts_or_invoices = [] + items = [] + + for item in doc.get('items'): + if item.get(doc_reference): + purchase_receipts_or_invoices.append(item.get(doc_reference)) + if item.get(items_reference): + items.append(item.get(items_reference)) + + exchange_rate_map = frappe._dict(frappe.get_all(parent_doctype, filters={'name': ('in', + purchase_receipts_or_invoices)}, fields=['name', 'conversion_rate'], as_list=1)) + + net_rate_map = frappe._dict(frappe.get_all(child_doctype, filters={'name': ('in', + items)}, fields=['name', 'net_rate'], as_list=1)) + + return exchange_rate_map, net_rate_map + def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context list_context = get_list_context(context) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 6dc0f56148b..db6f143eb80 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -16,6 +16,7 @@ from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_tra from erpnext.projects.doctype.project.test_project import make_project from erpnext.accounts.doctype.account.test_account import get_inventory_account, create_account from erpnext.stock.doctype.item.test_item import create_item +from erpnext.buying.doctype.supplier.test_supplier import create_supplier test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"] test_ignore = ["Serial No"] @@ -229,6 +230,27 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertEqual(expected_values[gle.account][1], gle.debit) self.assertEqual(expected_values[gle.account][2], gle.credit) + def test_purchase_invoice_with_exchange_rate_difference(self): + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice as create_purchase_invoice + + pr = make_purchase_receipt(company="_Test Company with perpetual inventory", warehouse='Stores - TCP1', + currency = "USD", conversion_rate = 70) + + pi = create_purchase_invoice(pr.name) + pi.conversion_rate = 80 + + pi.insert() + pi.submit() + + # Get exchnage gain and loss account + exchange_gain_loss_account = frappe.db.get_value('Company', pi.company, 'exchange_gain_loss_account') + + # fetching the latest GL Entry with exchange gain and loss account account + amount = frappe.db.get_value('GL Entry', {'account': exchange_gain_loss_account, 'voucher_no': pi.name}, 'debit') + discrepancy_caused_by_exchange_rate_diff = abs(pi.items[0].base_net_amount - pr.items[0].base_net_amount) + + self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + def test_purchase_invoice_change_naming_series(self): pi = frappe.copy_doc(test_records[1]) pi.insert() @@ -620,8 +642,10 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertEqual(actual_qty_0, get_qty_after_transaction()) def test_subcontracting_via_purchase_invoice(self): + from erpnext.buying.doctype.purchase_order.test_purchase_order import update_backflush_based_on from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + update_backflush_based_on('BOM') make_stock_entry(item_code="_Test Item", target="_Test Warehouse 1 - _TC", qty=100, basic_rate=100) make_stock_entry(item_code="_Test Item Home Desktop 100", target="_Test Warehouse 1 - _TC", qty=100, basic_rate=100) @@ -950,6 +974,217 @@ class TestPurchaseInvoice(unittest.TestCase): acc_settings.submit_journal_entriessubmit_journal_entries = 0 acc_settings.save() + def test_gain_loss_with_advance_entry(self): + 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) + + original_account = frappe.db.get_value("Company", "_Test Company", "exchange_gain_loss_account") + frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", "Exchange Gain/Loss - _TC") + + pay = frappe.get_doc({ + 'doctype': 'Payment Entry', + 'company': '_Test Company', + 'payment_type': 'Pay', + 'party_type': 'Supplier', + 'party': '_Test Supplier USD', + 'paid_to': '_Test Payable USD - _TC', + 'paid_from': 'Cash - _TC', + 'paid_amount': 70000, + 'target_exchange_rate': 70, + 'received_amount': 1000, + }) + pay.insert() + pay.submit() + + pi = make_purchase_invoice(supplier='_Test Supplier USD', currency="USD", + conversion_rate=75, rate=500, do_not_save=1, qty=1) + pi.cost_center = "_Test Cost Center - _TC" + pi.advances = [] + pi.append("advances", { + "reference_type": "Payment Entry", + "reference_name": pay.name, + "advance_amount": 1000, + "remarks": pay.remarks, + "allocated_amount": 500, + "ref_exchange_rate": 70 + }) + pi.save() + pi.submit() + + expected_gle = [ + ["_Test Account Cost for Goods Sold - _TC", 37500.0], + ["_Test Payable USD - _TC", -40000.0], + ["Exchange Gain/Loss - _TC", 2500.0] + ] + + gl_entries = frappe.db.sql(""" + select account, sum(debit - credit) as balance from `tabGL Entry` + where voucher_no=%s + group by account + order by account asc""", (pi.name), as_dict=1) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_gle[i][0], gle.account) + self.assertEqual(expected_gle[i][1], gle.balance) + + pi_2 = make_purchase_invoice(supplier='_Test Supplier USD', currency="USD", + conversion_rate=73, rate=500, do_not_save=1, qty=1) + pi_2.cost_center = "_Test Cost Center - _TC" + pi_2.advances = [] + pi_2.append("advances", { + "reference_type": "Payment Entry", + "reference_name": pay.name, + "advance_amount": 500, + "remarks": pay.remarks, + "allocated_amount": 500, + "ref_exchange_rate": 70 + }) + pi_2.save() + pi_2.submit() + + expected_gle = [ + ["_Test Account Cost for Goods Sold - _TC", 36500.0], + ["_Test Payable USD - _TC", -38000.0], + ["Exchange Gain/Loss - _TC", 1500.0] + ] + + gl_entries = frappe.db.sql(""" + select account, sum(debit - credit) as balance from `tabGL Entry` + where voucher_no=%s + group by account order by account asc""", (pi_2.name), as_dict=1) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_gle[i][0], gle.account) + self.assertEqual(expected_gle[i][1], gle.balance) + + expected_gle = [ + ["_Test Payable USD - _TC", 70000.0], + ["Cash - _TC", -70000.0] + ] + + gl_entries = frappe.db.sql(""" + select account, sum(debit - credit) as balance from `tabGL Entry` + where voucher_no=%s and is_cancelled=0 + group by account order by account asc""", (pay.name), as_dict=1) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_gle[i][0], gle.account) + self.assertEqual(expected_gle[i][1], gle.balance) + + pi.reload() + pi.cancel() + + pi_2.reload() + pi_2.cancel() + + pay.reload() + pay.cancel() + + frappe.db.set_value("Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled) + frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account) + + def test_purchase_invoice_advance_taxes(self): + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice + + # create a new supplier to test + supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier', + tax_withholding_category = 'TDS - 194 - Dividends - Individual') + + # Update tax withholding category with current fiscal year and rate details + update_tax_witholding_category('_Test Company', 'TDS Payable - _TC', nowdate()) + + # Create Purchase Order with TDS applied + po = create_purchase_order(do_not_save=1, supplier=supplier.name, rate=3000, item='_Test Non Stock Item') + po.apply_tds = 1 + po.tax_withholding_category = 'TDS - 194 - Dividends - Individual' + po.save() + po.submit() + + # Update Unrealized Profit / Loss Account which is used as default advance tax account + frappe.db.set_value('Company', '_Test Company', 'unrealized_profit_loss_account', '_Test Account Excise Duty - _TC') + + # Create Payment Entry Against the order + payment_entry = get_payment_entry(dt='Purchase Order', dn=po.name) + payment_entry.paid_from = 'Cash - _TC' + payment_entry.save() + payment_entry.submit() + + # Check GLE for Payment Entry + expected_gle = [ + ['_Test Account Excise Duty - _TC', 3000, 0], + ['Cash - _TC', 0, 27000], + ['Creditors - _TC', 27000, 0], + ['TDS Payable - _TC', 0, 3000], + ] + + gl_entries = frappe.db.sql("""select account, debit, credit + from `tabGL Entry` + where voucher_type='Payment Entry' and voucher_no=%s + order by account asc""", (payment_entry.name), as_dict=1) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_gle[i][0], gle.account) + self.assertEqual(expected_gle[i][1], gle.debit) + self.assertEqual(expected_gle[i][2], gle.credit) + + # Create Purchase Invoice against Purchase Order + purchase_invoice = get_mapped_purchase_invoice(po.name) + purchase_invoice.allocate_advances_automatically = 1 + purchase_invoice.items[0].item_code = '_Test Non Stock Item' + purchase_invoice.items[0].expense_account = '_Test Account Cost for Goods Sold - _TC' + purchase_invoice.save() + purchase_invoice.submit() + + # Check GLE for Purchase Invoice + # Zero net effect on final TDS Payable on invoice + expected_gle = [ + ['_Test Account Cost for Goods Sold - _TC', 30000], + ['_Test Account Excise Duty - _TC', -3000], + ['Creditors - _TC', -27000], + ['TDS Payable - _TC', 0] + ] + + gl_entries = frappe.db.sql("""select account, sum(debit - credit) as amount + from `tabGL Entry` + where voucher_type='Purchase Invoice' and voucher_no=%s + group by account + order by account asc""", (purchase_invoice.name), as_dict=1) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_gle[i][0], gle.account) + self.assertEqual(expected_gle[i][1], gle.amount) + +def update_tax_witholding_category(company, account, date): + from erpnext.accounts.utils import get_fiscal_year + + fiscal_year = get_fiscal_year(date=date, company=company) + + if not frappe.db.get_value('Tax Withholding Rate', + {'parent': 'TDS - 194 - Dividends - Individual', 'fiscal_year': fiscal_year[0]}): + tds_category = frappe.get_doc('Tax Withholding Category', 'TDS - 194 - Dividends - Individual') + tds_category.append('rates', { + 'fiscal_year': fiscal_year[0], + 'tax_withholding_rate': 10, + 'single_threshold': 2500, + 'cumulative_threshold': 0 + }) + tds_category.save() + + if not frappe.db.get_value('Tax Withholding Account', + {'parent': 'TDS - 194 - Dividends - Individual', 'account': account}): + tds_category = frappe.get_doc('Tax Withholding Category', 'TDS - 194 - Dividends - Individual') + tds_category.append('accounts', { + 'company': company, + 'account': account + }) + tds_category.save() def unlink_payment_on_cancel_of_invoice(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") diff --git a/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json b/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json index 5801b17f66f..63dfff8921f 100644 --- a/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json +++ b/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json @@ -1,235 +1,127 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2013-03-08 15:36:46", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, + "actions": [], + "creation": "2013-03-08 15:36:46", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_type", + "reference_name", + "remarks", + "reference_row", + "col_break1", + "advance_amount", + "allocated_amount", + "exchange_gain_loss", + "ref_exchange_rate" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reference_type", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Reference Type", - "length": 0, - "no_copy": 1, - "oldfieldname": "journal_voucher", - "oldfieldtype": "Link", - "options": "DocType", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "180px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "fieldname": "reference_type", + "fieldtype": "Link", + "label": "Reference Type", + "no_copy": 1, + "oldfieldname": "journal_voucher", + "oldfieldtype": "Link", + "options": "DocType", + "print_width": "180px", + "read_only": 1, "width": "180px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "reference_name", - "fieldtype": "Dynamic Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Reference Name", - "length": 0, - "no_copy": 1, - "options": "reference_type", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "columns": 3, + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "no_copy": 1, + "options": "reference_type", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "remarks", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Remarks", - "length": 0, - "no_copy": 1, - "oldfieldname": "remarks", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "150px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "columns": 3, + "fieldname": "remarks", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Remarks", + "no_copy": 1, + "oldfieldname": "remarks", + "oldfieldtype": "Small Text", + "print_width": "150px", + "read_only": 1, "width": "150px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reference_row", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Reference Row", - "length": 0, - "no_copy": 1, - "oldfieldname": "jv_detail_no", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "print_width": "80px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "fieldname": "reference_row", + "fieldtype": "Data", + "hidden": 1, + "label": "Reference Row", + "no_copy": 1, + "oldfieldname": "jv_detail_no", + "oldfieldtype": "Date", + "print_hide": 1, + "print_width": "80px", + "read_only": 1, "width": "80px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "col_break1", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "col_break1", + "fieldtype": "Column Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "advance_amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Advance Amount", - "length": 0, - "no_copy": 1, - "oldfieldname": "advance_amount", - "oldfieldtype": "Currency", - "options": "party_account_currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "100px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "columns": 2, + "fieldname": "advance_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Advance Amount", + "no_copy": 1, + "oldfieldname": "advance_amount", + "oldfieldtype": "Currency", + "options": "party_account_currency", + "print_width": "100px", + "read_only": 1, "width": "100px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "allocated_amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Allocated Amount", - "length": 0, - "no_copy": 1, - "oldfieldname": "allocated_amount", - "oldfieldtype": "Currency", - "options": "party_account_currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "100px", - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "columns": 2, + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Allocated Amount", + "no_copy": 1, + "oldfieldname": "allocated_amount", + "oldfieldtype": "Currency", + "options": "party_account_currency", + "print_width": "100px", "width": "100px" + }, + { + "fieldname": "exchange_gain_loss", + "fieldtype": "Currency", + "label": "Exchange Gain/Loss", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "ref_exchange_rate", + "fieldtype": "Float", + "label": "Reference Exchange Rate", + "non_negative": 1, + "read_only": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "menu_index": 0, - "modified": "2016-08-26 02:30:54.407138", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Purchase Invoice Advance", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_order": "DESC", - "track_seen": 0 + ], + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-04-20 16:26:53.820530", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Purchase Invoice Advance", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file 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 10e1c73ea90..b39022dd758 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -272,7 +272,7 @@ "fieldname": "rate", "fieldtype": "Currency", "in_list_view": 1, - "label": "Rate ", + "label": "Rate", "oldfieldname": "import_rate", "oldfieldtype": "Currency", "options": "currency", @@ -854,7 +854,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-03-30 09:02:39.256602", + "modified": "2021-06-16 19:43:51.099386", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json index f9fdc4b605a..1fa68e0a8a8 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json @@ -12,6 +12,7 @@ "charge_type", "row_id", "included_in_print_rate", + "included_in_paid_amount", "col_break1", "account_head", "description", @@ -21,6 +22,7 @@ "cost_center", "dimension_col_break", "section_break_9", + "currency", "tax_amount", "tax_amount_after_discount_amount", "total", @@ -205,12 +207,28 @@ { "fieldname": "dimension_col_break", "fieldtype": "Column Break" + }, + { + "fetch_from": "account_head.account_currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Account Currency", + "options": "Currency", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:['Purchase Taxes and Charges Template', 'Payment Entry'].includes(parent.doctype)", + "description": "If checked, the tax amount will be considered as already included in the Paid Amount in Payment Entry", + "fieldname": "included_in_paid_amount", + "fieldtype": "Check", + "label": "Considered In Paid Amount" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-09-18 17:26:09.703215", + "modified": "2021-06-14 01:43:50.750455", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Taxes and Charges", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index b735fde1ab7..bb556516702 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -17,7 +17,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e var me = this; super.onload(); - this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet']; + this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', 'POS Closing 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); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index e7dd6b8a606..0a9a105b7ca 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -48,6 +48,8 @@ "shipping_address", "company_address", "company_address_display", + "dispatch_address_name", + "dispatch_address", "currency_and_price_list", "currency", "conversion_rate", @@ -1966,6 +1968,21 @@ "fieldname": "disable_rounded_total", "fieldtype": "Check", "label": "Disable Rounded Total" + }, + { + "allow_on_submit": 1, + "fieldname": "dispatch_address_name", + "fieldtype": "Link", + "label": "Dispatch Address Name", + "options": "Address", + "print_hide": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "dispatch_address", + "fieldtype": "Small Text", + "label": "Dispatch Address", + "read_only": 1 } ], "icon": "fa fa-file-text", @@ -1978,7 +1995,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2021-05-20 22:48:33.988881", + "modified": "2021-07-08 14:03:55.502522", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 023f4b049c1..88899130a24 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -13,7 +13,7 @@ from erpnext.accounts.utils import get_account_currency from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.assets.doctype.asset.depreciation \ - import get_disposal_account_and_cost_center, get_gl_entries_on_asset_disposal + import get_disposal_account_and_cost_center, get_gl_entries_on_asset_disposal, get_gl_entries_on_asset_regain from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, get_delivery_note_serial_no from erpnext.setup.doctype.company.company import update_company_current_month_sales @@ -149,7 +149,7 @@ class SalesInvoice(SellingController): if self.update_stock: frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) - elif asset.status in ("Scrapped", "Cancelled", "Sold"): + elif asset.status in ("Scrapped", "Cancelled") or (asset.status == "Sold" and not self.is_return): frappe.throw(_("Row #{0}: Asset {1} cannot be submitted, it is already {2}").format(d.idx, d.asset, asset.status)) def validate_item_cost_centers(self): @@ -531,7 +531,7 @@ class SalesInvoice(SellingController): # set pos values in items for item in self.get("items"): if item.get('item_code'): - profile_details = get_pos_profile_item_details(pos, frappe._dict(item.as_dict()), pos) + profile_details = get_pos_profile_item_details(pos, frappe._dict(item.as_dict()), pos, update_data=True) for fname, val in iteritems(profile_details): if (not for_validate) or (for_validate and not item.get(fname)): item.set(fname, val) @@ -840,8 +840,11 @@ class SalesInvoice(SellingController): self.make_customer_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) + self.make_exchange_gain_loss_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) + self.allocate_advance_taxes(gl_entries) + self.make_item_gl_entries(gl_entries) # merge gl entries before adding pos entries @@ -849,7 +852,6 @@ class SalesInvoice(SellingController): self.make_loyalty_point_redemption_gle(gl_entries) self.make_pos_gl_entries(gl_entries) - self.make_gle_for_change_amount(gl_entries) self.make_write_off_gl_entry(gl_entries) self.make_gle_for_rounding_adjustment(gl_entries) @@ -916,22 +918,33 @@ class SalesInvoice(SellingController): for item in self.get("items"): if flt(item.base_net_amount, item.precision("base_net_amount")): if item.is_fixed_asset: - asset = frappe.get_doc("Asset", item.asset) - + if item.get('asset'): + asset = frappe.get_doc("Asset", item.asset) + else: + frappe.throw(_( + "Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_name), + title=_("Missing Asset") + ) if (len(asset.finance_books) > 1 and not item.finance_book and asset.finance_books[0].finance_book): frappe.throw(_("Select finance book for the item {0} at row {1}") .format(item.item_code, item.idx)) - fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(asset, - item.base_net_amount, item.finance_book) + if self.is_return: + fixed_asset_gl_entries = get_gl_entries_on_asset_regain(asset, + item.base_net_amount, item.finance_book) + asset.db_set("disposal_date", None) + else: + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(asset, + item.base_net_amount, item.finance_book) + asset.db_set("disposal_date", self.posting_date) for gle in fixed_asset_gl_entries: gle["against"] = self.customer gl_entries.append(self.get_gl_dict(gle, item=item)) - asset.db_set("disposal_date", self.posting_date) - asset.set_status("Sold" if self.docstatus==1 else None) + self.set_asset_status(asset) + else: # Do not book income for transfer within same company if not self.is_internal_transfer(): @@ -957,6 +970,12 @@ class SalesInvoice(SellingController): erpnext.is_perpetual_inventory_enabled(self.company): gl_entries += super(SalesInvoice, self).get_gl_entries() + def set_asset_status(self, asset): + if self.is_return: + asset.set_status() + else: + asset.set_status("Sold" if self.docstatus==1 else None) + def make_loyalty_point_redemption_gle(self, gl_entries): if cint(self.redeem_loyalty_points): gl_entries.append( @@ -983,7 +1002,13 @@ class SalesInvoice(SellingController): def make_pos_gl_entries(self, gl_entries): if cint(self.is_pos): + + skip_change_gl_entries = not cint(frappe.db.get_single_value('Accounts Settings', 'post_change_gl_entries')) + for payment_mode in self.payments: + if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount: + payment_mode.base_amount -= flt(self.change_amount) + if payment_mode.amount: # POS, make payment entries gl_entries.append( @@ -1015,8 +1040,11 @@ class SalesInvoice(SellingController): }, payment_mode_account_currency, item=self) ) + if not skip_change_gl_entries: + self.make_gle_for_change_amount(gl_entries) + def make_gle_for_change_amount(self, gl_entries): - if cint(self.is_pos) and self.change_amount: + if self.change_amount: if self.account_for_change_amount: gl_entries.append( self.get_gl_dict({ diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index df6d4839041..c6e6e3da6f1 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -10,6 +10,7 @@ from frappe.model.dynamic_links import get_dynamic_link_map from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry, get_qty_after_transaction from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import unlink_payment_on_cancel_of_invoice from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile +from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency from erpnext.stock.doctype.serial_no.serial_no import SerialNoWarehouseError from frappe.model.naming import make_autoname @@ -713,7 +714,7 @@ class TestSalesInvoice(unittest.TestCase): si.submit() self.assertEqual(si.paid_amount, 100.0) - self.pos_gl_entry(si, pos, 50) + self.validate_pos_gl_entry(si, pos, 50) def test_pos_returns_with_repayment(self): from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return @@ -749,7 +750,7 @@ class TestSalesInvoice(unittest.TestCase): make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") - pr = make_purchase_receipt(company= "_Test Company with perpetual inventory", + make_purchase_receipt(company= "_Test Company with perpetual inventory", item_code= "_Test FG Item",warehouse= "Stores - TCP1", cost_center= "Main - TCP1") pos = create_sales_invoice(company= "_Test Company with perpetual inventory", @@ -770,7 +771,45 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(pos.grand_total, 100.0) self.assertEqual(pos.write_off_amount, -5) - def pos_gl_entry(self, si, pos, cash_amount): + def test_pos_with_no_gl_entry_for_change_amount(self): + frappe.db.set_value('Accounts Settings', None, 'post_change_gl_entries', 0) + + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") + + make_purchase_receipt(company= "_Test Company with perpetual inventory", + item_code= "_Test FG Item",warehouse= "Stores - TCP1", cost_center= "Main - TCP1") + + pos = create_sales_invoice(company= "_Test Company with perpetual inventory", + debit_to="Debtors - TCP1", item_code= "_Test FG Item", warehouse="Stores - TCP1", + income_account = "Sales - TCP1", expense_account = "Cost of Goods Sold - TCP1", + cost_center = "Main - TCP1", do_not_save=True) + + pos.is_pos = 1 + pos.update_stock = 1 + + taxes = get_taxes_and_charges() + pos.taxes = [] + for tax in taxes: + pos.append("taxes", tax) + + pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 50}) + pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - TCP1', 'amount': 60}) + + pos.insert() + pos.submit() + + self.assertEqual(pos.grand_total, 100.0) + self.assertEqual(pos.change_amount, 10) + + self.validate_pos_gl_entry(pos, pos, 60, validate_without_change_gle=True) + + frappe.db.set_value('Accounts Settings', None, 'post_change_gl_entries', 1) + + def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False): + if validate_without_change_gle: + cash_amount -= pos.change_amount + # check stock ledger entries sle = frappe.db.sql("""select * from `tabStock Ledger Entry` where voucher_type = 'Sales Invoice' and voucher_no = %s""", @@ -1031,6 +1070,36 @@ class TestSalesInvoice(unittest.TestCase): self.assertFalse(si1.outstanding_amount) self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 1500) + def test_gle_made_when_asset_is_returned(self): + create_asset_data() + asset = create_asset(item_code="Macbook Pro") + + si = create_sales_invoice(item_code="Macbook Pro", asset=asset.name, qty=1, rate=90000) + return_si = create_sales_invoice(is_return=1, return_against=si.name, item_code="Macbook Pro", asset=asset.name, qty=-1, rate=90000) + + disposal_account = frappe.get_cached_value("Company", "_Test Company", "disposal_account") + + # Asset value is 100,000 but it was sold for 90,000, so there should be a loss of 10,000 + loss_for_si = frappe.get_all( + "GL Entry", + filters = { + "voucher_no": si.name, + "account": disposal_account + }, + fields = ["credit", "debit"] + )[0] + + loss_for_return_si = frappe.get_all( + "GL Entry", + filters = { + "voucher_no": return_si.name, + "account": disposal_account + }, + fields = ["credit", "debit"] + )[0] + + self.assertEqual(loss_for_si['credit'], loss_for_return_si['debit']) + self.assertEqual(loss_for_si['debit'], loss_for_return_si['credit']) def test_discount_on_net_total(self): si = frappe.copy_doc(test_records[2]) @@ -1870,6 +1939,8 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(data['billLists'][0]['sgstValue'], 5400) self.assertEqual(data['billLists'][0]['vehicleNo'], 'KA12KA1234') self.assertEqual(data['billLists'][0]['itemList'][0]['taxableAmount'], 60000) + self.assertEqual(data['billLists'][0]['actualFromStateCode'],7) + self.assertEqual(data['billLists'][0]['fromStateCode'],27) def test_einvoice_submission_without_irn(self): # init @@ -1899,69 +1970,80 @@ class TestSalesInvoice(unittest.TestCase): frappe.flags.country = country def test_einvoice_json(self): - from erpnext.regional.india.e_invoice.utils import make_einvoice + from erpnext.regional.india.e_invoice.utils import make_einvoice, validate_totals - si = make_sales_invoice_for_ewaybill() - si.naming_series = 'INV-2020-.#####' - si.items = [] - si.append("items", { - "item_code": "_Test Item", - "uom": "Nos", - "warehouse": "_Test Warehouse - _TC", - "qty": 2000, - "rate": 12, - "income_account": "Sales - _TC", - "expense_account": "Cost of Goods Sold - _TC", - "cost_center": "_Test Cost Center - _TC", - }) - si.append("items", { - "item_code": "_Test Item 2", - "uom": "Nos", - "warehouse": "_Test Warehouse - _TC", - "qty": 420, - "rate": 15, - "income_account": "Sales - _TC", - "expense_account": "Cost of Goods Sold - _TC", - "cost_center": "_Test Cost Center - _TC", - }) + si = get_sales_invoice_for_e_invoice() si.discount_amount = 100 si.save() einvoice = make_einvoice(si) - - total_item_ass_value = 0 - total_item_cgst_value = 0 - total_item_sgst_value = 0 - total_item_igst_value = 0 - total_item_value = 0 - - for item in einvoice['ItemList']: - total_item_ass_value += item['AssAmt'] - total_item_cgst_value += item['CgstAmt'] - total_item_sgst_value += item['SgstAmt'] - total_item_igst_value += item['IgstAmt'] - total_item_value += item['TotItemVal'] - - self.assertTrue(item['AssAmt'], item['TotAmt'] - item['Discount']) - self.assertTrue(item['TotItemVal'], item['AssAmt'] + item['CgstAmt'] + item['SgstAmt'] + item['IgstAmt']) - - value_details = einvoice['ValDtls'] - - self.assertEqual(einvoice['Version'], '1.1') - self.assertEqual(value_details['AssVal'], total_item_ass_value) - self.assertEqual(value_details['CgstVal'], total_item_cgst_value) - self.assertEqual(value_details['SgstVal'], total_item_sgst_value) - self.assertEqual(value_details['IgstVal'], total_item_igst_value) - - calculated_invoice_value = \ - value_details['AssVal'] + value_details['CgstVal'] \ - + value_details['SgstVal'] + value_details['IgstVal'] \ - + value_details['OthChrg'] - value_details['Discount'] - - self.assertTrue(value_details['TotInvVal'] - calculated_invoice_value < 0.1) - - self.assertEqual(value_details['TotInvVal'], si.base_grand_total) self.assertTrue(einvoice['EwbDtls']) + validate_totals(einvoice) + + si.apply_discount_on = 'Net Total' + si.save() + einvoice = make_einvoice(si) + validate_totals(einvoice) + + [d.set('included_in_print_rate', 1) for d in si.taxes] + si.save() + einvoice = make_einvoice(si) + validate_totals(einvoice) + + def test_item_tax_net_range(self): + item = create_item("T Shirt") + + item.set('taxes', []) + item.append("taxes", { + "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", + "minimum_net_rate": 0, + "maximum_net_rate": 500 + }) + + item.append("taxes", { + "item_tax_template": "_Test Account Excise Duty @ 12 - _TC", + "minimum_net_rate": 501, + "maximum_net_rate": 1000 + }) + + item.save() + + sales_invoice = create_sales_invoice(item = "T Shirt", rate=700, do_not_submit=True) + self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 12 - _TC") + + # Apply discount + sales_invoice.apply_discount_on = 'Net Total' + sales_invoice.discount_amount = 300 + sales_invoice.save() + self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC") + +def get_sales_invoice_for_e_invoice(): + si = make_sales_invoice_for_ewaybill() + si.naming_series = 'INV-2020-.#####' + si.items = [] + si.append("items", { + "item_code": "_Test Item", + "uom": "Nos", + "warehouse": "_Test Warehouse - _TC", + "qty": 2000, + "rate": 12, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }) + + si.append("items", { + "item_code": "_Test Item 2", + "uom": "Nos", + "warehouse": "_Test Warehouse - _TC", + "qty": 420, + "rate": 15, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }) + + return si def make_test_address_for_ewaybill(): if not frappe.db.exists('Address', '_Test Address for Eway bill-Billing'): @@ -2012,6 +2094,30 @@ def make_test_address_for_ewaybill(): address.save() + if not frappe.db.exists('Address', '_Test Dispatch-Address for Eway bill-Shipping'): + address = frappe.get_doc({ + "address_line1": "_Test Dispatch Address Line 1", + "address_title": "_Test Dispatch-Address for Eway bill", + "address_type": "Shipping", + "city": "_Test City", + "state": "Test State", + "country": "India", + "doctype": "Address", + "is_primary_address": 0, + "phone": "+910000000000", + "gstin": "07AAACC1206D1ZI", + "gst_state": "Delhi", + "gst_state_number": "07", + "pincode": "1100101" + }).insert() + + address.append("links", { + "link_doctype": "Company", + "link_name": "_Test Company" + }) + + address.save() + def make_test_transporter_for_ewaybill(): if not frappe.db.exists('Supplier', '_Test Transporter'): frappe.get_doc({ @@ -2038,9 +2144,9 @@ def make_sales_invoice_for_ewaybill(): if not gst_account: gst_settings.append("gst_accounts", { "company": "_Test Company", - "cgst_account": "CGST - _TC", - "sgst_account": "SGST - _TC", - "igst_account": "IGST - _TC", + "cgst_account": "Output Tax CGST - _TC", + "sgst_account": "Output Tax SGST - _TC", + "igst_account": "Output Tax IGST - _TC", }) gst_settings.save() @@ -2050,6 +2156,7 @@ def make_sales_invoice_for_ewaybill(): si.distance = 2000 si.company_address = "_Test Address for Eway bill-Billing" si.customer_address = "_Test Customer-Address for Eway bill-Shipping" + si.dispatch_address_name = "_Test Dispatch-Address for Eway bill-Shipping" si.vehicle_no = "KA12KA1234" si.gst_category = "Registered Regular" si.mode_of_transport = 'Road' @@ -2057,7 +2164,7 @@ def make_sales_invoice_for_ewaybill(): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "CGST - _TC", + "account_head": "Output Tax CGST - _TC", "cost_center": "Main - _TC", "description": "CGST @ 9.0", "rate": 9 @@ -2065,7 +2172,7 @@ def make_sales_invoice_for_ewaybill(): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "SGST - _TC", + "account_head": "Output Tax SGST - _TC", "cost_center": "Main - _TC", "description": "SGST @ 9.0", "rate": 9 @@ -2085,27 +2192,6 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date): doc.assertEqual(expected_gle[i][2], gle.credit) doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date) - def test_item_tax_validity(self): - item = frappe.get_doc("Item", "_Test Item 2") - - if item.taxes: - item.taxes = [] - item.save() - - item.append("taxes", { - "item_tax_template": "_Test Item Tax Template 1 - _TC", - "valid_from": add_days(nowdate(), 1) - }) - - item.save() - - sales_invoice = create_sales_invoice(item = "_Test Item 2", do_not_save=1) - sales_invoice.items[0].item_tax_template = "_Test Item Tax Template 1 - _TC" - self.assertRaises(frappe.ValidationError, sales_invoice.save) - - item.taxes = [] - item.save() - def create_sales_invoice(**args): si = frappe.new_doc("Sales Invoice") args = frappe._dict(args) @@ -2136,6 +2222,7 @@ def create_sales_invoice(**args): "rate": args.rate if args.get("rate") is not None else 100, "income_account": args.income_account or "Sales - _TC", "expense_account": args.expense_account or "Cost of Goods Sold - _TC", + "asset": args.asset or None, "cost_center": args.cost_center or "_Test Cost Center - _TC", "serial_no": args.serial_no, "conversion_factor": 1 diff --git a/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json b/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json index 14bf4d81330..29422d68cf6 100644 --- a/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json +++ b/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json @@ -1,235 +1,128 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2013-02-22 01:27:41", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, + "actions": [], + "creation": "2013-02-22 01:27:41", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_type", + "reference_name", + "remarks", + "reference_row", + "col_break1", + "advance_amount", + "allocated_amount", + "exchange_gain_loss", + "ref_exchange_rate" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reference_type", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Reference Type", - "length": 0, - "no_copy": 1, - "oldfieldname": "journal_voucher", - "oldfieldtype": "Link", - "options": "DocType", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "250px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "fieldname": "reference_type", + "fieldtype": "Link", + "label": "Reference Type", + "no_copy": 1, + "oldfieldname": "journal_voucher", + "oldfieldtype": "Link", + "options": "DocType", + "print_width": "250px", + "read_only": 1, "width": "250px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "reference_name", - "fieldtype": "Dynamic Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Reference Name", - "length": 0, - "no_copy": 1, - "options": "reference_type", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "columns": 3, + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "no_copy": 1, + "options": "reference_type", + "print_hide": 1, + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "remarks", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Remarks", - "length": 0, - "no_copy": 1, - "oldfieldname": "remarks", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "150px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "columns": 3, + "fieldname": "remarks", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Remarks", + "no_copy": 1, + "oldfieldname": "remarks", + "oldfieldtype": "Small Text", + "print_width": "150px", + "read_only": 1, "width": "150px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reference_row", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Reference Row", - "length": 0, - "no_copy": 1, - "oldfieldname": "jv_detail_no", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "print_width": "120px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "fieldname": "reference_row", + "fieldtype": "Data", + "hidden": 1, + "label": "Reference Row", + "no_copy": 1, + "oldfieldname": "jv_detail_no", + "oldfieldtype": "Data", + "print_hide": 1, + "print_width": "120px", + "read_only": 1, "width": "120px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "col_break1", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "col_break1", + "fieldtype": "Column Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "advance_amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Advance amount", - "length": 0, - "no_copy": 1, - "oldfieldname": "advance_amount", - "oldfieldtype": "Currency", - "options": "party_account_currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "120px", - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "columns": 2, + "fieldname": "advance_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Advance amount", + "no_copy": 1, + "oldfieldname": "advance_amount", + "oldfieldtype": "Currency", + "options": "party_account_currency", + "print_width": "120px", + "read_only": 1, "width": "120px" - }, + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "allocated_amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Allocated amount", - "length": 0, - "no_copy": 1, - "oldfieldname": "allocated_amount", - "oldfieldtype": "Currency", - "options": "party_account_currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "120px", - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "columns": 2, + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Allocated amount", + "no_copy": 1, + "oldfieldname": "allocated_amount", + "oldfieldtype": "Currency", + "options": "party_account_currency", + "print_width": "120px", "width": "120px" + }, + { + "fieldname": "exchange_gain_loss", + "fieldtype": "Currency", + "label": "Exchange Gain/Loss", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "ref_exchange_rate", + "fieldtype": "Float", + "label": "Reference Exchange Rate", + "non_negative": 1, + "read_only": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "menu_index": 0, - "modified": "2016-08-26 02:36:10.718057", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Sales Invoice Advance", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_order": "DESC", - "track_seen": 0 + ], + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-06-04 20:25:49.832052", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Sales Invoice Advance", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index 8e6952a93c4..6690bdafc34 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -743,7 +743,6 @@ "fieldname": "asset", "fieldtype": "Link", "label": "Asset", - "no_copy": 1, "options": "Asset" }, { @@ -826,7 +825,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-02-23 01:05:22.123527", + "modified": "2021-06-21 23:03:11.599901", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json index 3c8cb6b8518..1b7a0fe562e 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json +++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json @@ -1,8 +1,10 @@ { + "actions": [], "creation": "2013-04-24 11:39:32", "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "charge_type", "row_id", @@ -10,12 +12,14 @@ "col_break_1", "description", "included_in_print_rate", + "included_in_paid_amount", "accounting_dimensions_section", "cost_center", "dimension_col_break", "section_break_8", "rate", "section_break_9", + "currency", "tax_amount", "total", "tax_amount_after_discount_amount", @@ -23,8 +27,7 @@ "base_tax_amount", "base_total", "base_tax_amount_after_discount_amount", - "item_wise_tax_detail", - "parenttype" + "item_wise_tax_detail" ], "fields": [ { @@ -173,17 +176,6 @@ "oldfieldtype": "Small Text", "read_only": 1 }, - { - "fieldname": "parenttype", - "fieldtype": "Data", - "hidden": 1, - "in_filter": 1, - "label": "Parenttype", - "oldfieldname": "parenttype", - "oldfieldtype": "Data", - "print_hide": 1, - "search_index": 1 - }, { "fieldname": "accounting_dimensions_section", "fieldtype": "Section Break", @@ -192,15 +184,34 @@ { "fieldname": "dimension_col_break", "fieldtype": "Column Break" + }, + { + "fetch_from": "account_head.account_currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Account Currency", + "options": "Currency", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:['Sales Taxes and Charges Template', 'Payment Entry'].includes(parent.doctype)", + "description": "If checked, the tax amount will be considered as already included in the Paid Amount in Payment Entry", + "fieldname": "included_in_paid_amount", + "fieldtype": "Check", + "label": "Considered In Paid Amount" } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-05-25 22:59:38.740883", + "links": [], + "modified": "2021-06-14 01:44:36.899147", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Taxes and Charges", "owner": "Administrator", "permissions": [], + "sort_field": "modified", "sort_order": "ASC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.js b/erpnext/accounts/doctype/tax_rule/tax_rule.js index 370890e4d8e..bc497163e8b 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.js +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.js @@ -1,24 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.add_fetch("customer", "customer_group", "customer_group" ); -cur_frm.add_fetch("supplier", "supplier_group_name", "supplier_group" ); - -frappe.ui.form.on("Tax Rule", "tax_type", function(frm) { - frm.toggle_reqd("sales_tax_template", frm.doc.tax_type=="Sales"); - frm.toggle_reqd("purchase_tax_template", frm.doc.tax_type=="Purchase"); -}) - -frappe.ui.form.on("Tax Rule", "onload", function(frm) { - if(frm.doc.__islocal) { - frm.set_value("use_for_shopping_cart", 1); - } -}) - -frappe.ui.form.on("Tax Rule", "refresh", function(frm) { - frappe.ui.form.trigger("Tax Rule", "tax_type"); -}) - frappe.ui.form.on("Tax Rule", "customer", function(frm) { if(frm.doc.customer) { frappe.call({ diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.json b/erpnext/accounts/doctype/tax_rule/tax_rule.json index ef155381c0b..27467484329 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.json +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.json @@ -1,1103 +1,250 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "ACC-TAX-RULE-.YYYY.-.#####", - "beta": 0, - "creation": "2015-08-07 02:33:52.670866", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "autoname": "ACC-TAX-RULE-.YYYY.-.#####", + "creation": "2015-08-07 02:33:52.670866", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "tax_type", + "use_for_shopping_cart", + "column_break_1", + "sales_tax_template", + "purchase_tax_template", + "filters", + "customer", + "supplier", + "item", + "billing_city", + "billing_county", + "billing_state", + "billing_zipcode", + "billing_country", + "tax_category", + "column_break_2", + "customer_group", + "supplier_group", + "item_group", + "shipping_city", + "shipping_county", + "shipping_state", + "shipping_zipcode", + "shipping_country", + "section_break_4", + "from_date", + "column_break_7", + "to_date", + "section_break_6", + "priority", + "column_break_20", + "company" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Sales", - "fieldname": "tax_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": 1, - "label": "Tax Type", - "length": 0, - "no_copy": 0, - "options": "Sales\nPurchase", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Sales", + "fieldname": "tax_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Tax Type", + "options": "Sales\nPurchase" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "use_for_shopping_cart", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Use for Shopping Cart", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "fieldname": "use_for_shopping_cart", + "fieldtype": "Check", + "label": "Use for Shopping Cart" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_1", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Sales\"", - "fieldname": "sales_tax_template", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Sales Tax Template", - "length": 0, - "no_copy": 0, - "options": "Sales Taxes and Charges Template", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Sales\"", + "fieldname": "sales_tax_template", + "fieldtype": "Link", + "label": "Sales Tax Template", + "options": "Sales Taxes and Charges Template" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Purchase\"", - "fieldname": "purchase_tax_template", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Purchase Tax Template", - "length": 0, - "no_copy": 0, - "options": "Purchase Taxes and Charges Template", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Purchase\"", + "fieldname": "purchase_tax_template", + "fieldtype": "Link", + "label": "Purchase Tax Template", + "options": "Purchase Taxes and Charges Template" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "filters", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Filters", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "filters", + "fieldtype": "Section Break", + "label": "Filters" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Sales\"", - "fieldname": "customer", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer", - "length": 0, - "no_copy": 0, - "options": "Customer", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Sales\"", + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Purchase\"", - "fieldname": "supplier", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Supplier", - "length": 0, - "no_copy": 0, - "options": "Supplier", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Purchase\"", + "fieldname": "supplier", + "fieldtype": "Link", + "label": "Supplier", + "options": "Supplier" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item", - "length": 0, - "no_copy": 0, - "options": "Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item", + "fieldtype": "Link", + "label": "Item", + "options": "Item" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_city", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing City", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_city", + "fieldtype": "Data", + "label": "Billing City" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_county", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing County", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_county", + "fieldtype": "Data", + "label": "Billing County" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_state", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing State", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_state", + "fieldtype": "Data", + "label": "Billing State" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_zipcode", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing Zipcode", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_zipcode", + "fieldtype": "Data", + "label": "Billing Zipcode" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_country", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing Country", - "length": 0, - "no_copy": 0, - "options": "Country", - "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": "billing_country", + "fieldtype": "Link", + "label": "Billing Country", + "options": "Country" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "tax_category", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Tax Category", - "length": 0, - "no_copy": 0, - "options": "Tax Category", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "tax_category", + "fieldtype": "Link", + "label": "Tax Category", + "options": "Tax Category" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Sales\"", - "fieldname": "customer_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Group", - "length": 0, - "no_copy": 0, - "options": "Customer Group", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Sales\"", + "fetch_from": "customer.customer_group", + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Purchase\"", - "fieldname": "supplier_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Supplier Group", - "length": 0, - "no_copy": 0, - "options": "Supplier Group", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Purchase\"", + "fetch_from": "supplier.supplier_group", + "fieldname": "supplier_group", + "fieldtype": "Link", + "label": "Supplier Group", + "options": "Supplier Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item Group", - "length": 0, - "no_copy": 0, - "options": "Item Group", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_city", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping City", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_city", + "fieldtype": "Data", + "label": "Shipping City" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_county", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping County", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_county", + "fieldtype": "Data", + "label": "Shipping County" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_state", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping State", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_state", + "fieldtype": "Data", + "label": "Shipping State" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_zipcode", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping Zipcode", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_zipcode", + "fieldtype": "Data", + "label": "Shipping Zipcode" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_country", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping Country", - "length": 0, - "no_copy": 0, - "options": "Country", - "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": "shipping_country", + "fieldtype": "Link", + "label": "Shipping Country", + "options": "Country" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_4", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Validity", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Validity" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "from_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "From Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_7", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "to_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "To Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_6", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "1", - "fieldname": "priority", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Priority", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "fieldname": "priority", + "fieldtype": "Int", + "label": "Priority" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_20", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "remember_last_selected_value": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-12-27 01:22:17.721636", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Tax Rule", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2021-06-04 23:14:27.186879", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Tax Rule", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py index ac1ffd9e750..cf7226822ed 100644 --- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py @@ -50,7 +50,7 @@ class TestTaxRule(unittest.TestCase): tax_rule1 = make_tax_rule(customer_group= "All Customer Groups", sales_tax_template = "_Test Sales Taxes and Charges Template - _TC", priority = 1, from_date = "2015-01-01") tax_rule1.save() - self.assertEqual(get_tax_template("2015-01-01", {"customer_group" : "Commercial", "use_for_shopping_cart":0}), + self.assertEqual(get_tax_template("2015-01-01", {"customer_group" : "Commercial", "use_for_shopping_cart":1}), "_Test Sales Taxes and Charges Template - _TC") def test_conflict_with_overlapping_dates(self): diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 5c1cbaa4aaa..b9ee4a0963f 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -49,7 +49,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None): if not parties: parties.append(party) - fiscal_year = get_fiscal_year(inv.posting_date, company=inv.company) + fiscal_year = get_fiscal_year(inv.get('posting_date') or inv.get('transaction_date'), company=inv.company) tax_details = get_tax_withholding_details(tax_withholding_category, fiscal_year[0], inv.company) if not tax_details: @@ -154,7 +154,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, fiscal_year_details, p tax_deducted = get_deducted_tax(taxable_vouchers, fiscal_year, tax_details) tax_amount = 0 - posting_date = inv.posting_date + posting_date = inv.get('posting_date') or inv.get('transaction_date') if party_type == 'Supplier': ldc = get_lower_deduction_certificate(fiscal_year, pan_no) if tax_deducted: @@ -257,7 +257,7 @@ def get_tds_amount(ldc, parties, inv, tax_details, fiscal_year_details, tax_dedu if ((threshold and inv.net_total >= threshold) or (cumulative_threshold and supp_credit_amt >= cumulative_threshold)): if ldc and is_valid_certificate( ldc.valid_from, ldc.valid_upto, - inv.posting_date, tax_deducted, + inv.get('posting_date') or inv.get('transaction_date'), tax_deducted, inv.net_total, ldc.certificate_limit ): tds_amount = get_ltds_amount(supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 968ab49b312..25d2cf10bd4 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -101,7 +101,7 @@ def merge_similar_entries(gl_map, precision=None): def check_if_in_list(gle, gl_map, dimensions=None): account_head_fieldnames = ['party_type', 'party', 'against_voucher', 'against_voucher_type', - 'cost_center', 'project'] + 'cost_center', 'project', 'voucher_detail_no'] if dimensions: account_head_fieldnames = account_head_fieldnames + dimensions @@ -185,10 +185,10 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision): for d in gl_map: if d.account == round_off_account: round_off_gle = d - if d.debit_in_account_currency: - debit_credit_diff -= flt(d.debit_in_account_currency) + if d.debit: + debit_credit_diff -= flt(d.debit) else: - debit_credit_diff += flt(d.credit_in_account_currency) + debit_credit_diff += flt(d.credit) round_off_account_exists = True if round_off_account_exists and abs(debit_credit_diff) <= (1.0 / (10**precision)): diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index e025fc69054..b97dc401e6a 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -542,6 +542,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None): select company, sum(debit_in_account_currency) - sum(credit_in_account_currency) from `tabGL Entry` where party_type = %s and party=%s + and is_cancelled = 0 group by company""", (party_type, party))) for d in companies: diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index db605f7285a..b54646fd27a 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -99,7 +99,6 @@ class ReceivablePayableReport(object): voucher_no = gle.voucher_no, party = gle.party, posting_date = gle.posting_date, - remarks = gle.remarks, account_currency = gle.account_currency, invoiced = 0.0, paid = 0.0, @@ -579,11 +578,12 @@ class ReceivablePayableReport(object): self.gl_entries = frappe.db.sql(""" select name, posting_date, account, party_type, party, voucher_type, voucher_no, cost_center, - against_voucher_type, against_voucher, account_currency, remarks, {0} + against_voucher_type, against_voucher, account_currency, {0} from `tabGL Entry` where docstatus < 2 + and is_cancelled = 0 and party_type=%s and (party is not null and party != '') {1} {2} {3}""" @@ -791,8 +791,6 @@ class ReceivablePayableReport(object): self.add_column(label=_('Supplier Group'), fieldname='supplier_group', fieldtype='Link', options='Supplier Group') - self.add_column(label=_('Remarks'), fieldname='remarks', fieldtype='Text', width=200) - def add_column(self, label, fieldname=None, fieldtype='Currency', options=None, width=120): if not fieldname: fieldname = scrub(label) if fieldtype=='Currency': options='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 9c9ada871c8..f1b231b6901 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -397,6 +397,7 @@ def get_chart_data(filters, columns, data): {'name': 'Budget', 'chartType': 'bar', 'values': budget_values}, {'name': 'Actual Expense', 'chartType': 'bar', 'values': actual_values} ] - } + }, + 'type' : 'bar' } diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 7793af737f9..56a67bb0989 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -380,7 +380,7 @@ def set_gl_entries_by_account(from_date, to_date, root_lft, root_rgt, filters, g gl_entries = frappe.db.sql("""select gl.posting_date, gl.account, gl.debit, gl.credit, gl.is_opening, gl.company, gl.fiscal_year, gl.debit_in_account_currency, gl.credit_in_account_currency, gl.account_currency, acc.account_name, acc.account_number - from `tabGL Entry` gl, `tabAccount` acc where acc.name = gl.account and gl.company = %(company)s + from `tabGL Entry` gl, `tabAccount` acc where acc.name = gl.account and gl.company = %(company)s and gl.is_cancelled = 0 {additional_conditions} and gl.posting_date <= %(to_date)s and acc.lft >= %(lft)s and acc.rgt <= %(rgt)s order by gl.account, gl.posting_date""".format(additional_conditions=additional_conditions), { diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index 84f786814de..4a551b80124 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -36,16 +36,12 @@ frappe.query_reports["General Ledger"] = { { "fieldname":"account", "label": __("Account"), - "fieldtype": "Link", + "fieldtype": "MultiSelectList", "options": "Account", - "get_query": function() { - var company = frappe.query_report.get_filter_value('company'); - return { - "doctype": "Account", - "filters": { - "company": company, - } - } + get_data: function(txt) { + return frappe.db.get_link_options('Account', txt, { + company: frappe.query_report.get_filter_value("company") + }); } }, { @@ -135,7 +131,9 @@ frappe.query_reports["General Ledger"] = { "label": __("Cost Center"), "fieldtype": "MultiSelectList", get_data: function(txt) { - return frappe.db.get_link_options('Cost Center', txt); + return frappe.db.get_link_options('Cost Center', txt, { + company: frappe.query_report.get_filter_value("company") + }); } }, { @@ -143,7 +141,9 @@ frappe.query_reports["General Ledger"] = { "label": __("Project"), "fieldtype": "MultiSelectList", get_data: function(txt) { - return frappe.db.get_link_options('Project', txt); + return frappe.db.get_link_options('Project', txt, { + company: frappe.query_report.get_filter_value("company") + }); } }, { diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 562df4f6f7d..1759fa3a48f 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -48,13 +48,18 @@ def validate_filters(filters, account_details): if not filters.get("from_date") and not filters.get("to_date"): frappe.throw(_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))) + + if filters.get('account'): + filters.account = frappe.parse_json(filters.get('account')) + for account in filters.account: + if not account_details.get(account): + frappe.throw(_("Account {0} does not exists").format(account)) - if filters.get("account") and not account_details.get(filters.account): - frappe.throw(_("Account {0} does not exists").format(filters.account)) - - if (filters.get("account") and filters.get("group_by") == _('Group by Account') - and account_details[filters.account].is_group == 0): - frappe.throw(_("Can not filter based on Account, if grouped by Account")) + if (filters.get("account") and filters.get("group_by") == _('Group by Account')): + filters.account = frappe.parse_json(filters.get('account')) + for account in filters.account: + if account_details[account].is_group == 0: + frappe.throw(_("Can not filter based on Child Account, if grouped by Account")) if (filters.get("voucher_no") and filters.get("group_by") in [_('Group by Voucher')]): @@ -87,7 +92,19 @@ def set_account_currency(filters): account_currency = None if filters.get("account"): - account_currency = get_account_currency(filters.account) + if len(filters.get("account")) == 1: + account_currency = get_account_currency(filters.account[0]) + else: + currency = get_account_currency(filters.account[0]) + is_same_account_currency = True + for account in filters.get("account"): + if get_account_currency(account) != currency: + is_same_account_currency = False + break + + if is_same_account_currency: + account_currency = currency + elif filters.get("party"): gle_currency = frappe.db.get_value( "GL Entry", { @@ -205,10 +222,10 @@ def get_gl_entries(filters, accounting_dimensions): def get_conditions(filters): conditions = [] - if filters.get("account") and not filters.get("include_dimensions"): - lft, rgt = frappe.db.get_value("Account", filters["account"], ["lft", "rgt"]) - conditions.append("""account in (select name from tabAccount - where lft>=%s and rgt<=%s and docstatus<2)""" % (lft, rgt)) + + if filters.get("account"): + filters.account = get_accounts_with_children(filters.account) + conditions.append("account in %(account)s") if filters.get("cost_center"): filters.cost_center = get_cost_centers_with_children(filters.cost_center) @@ -266,6 +283,20 @@ def get_conditions(filters): return "and {}".format(" and ".join(conditions)) if conditions else "" +def get_accounts_with_children(accounts): + if not isinstance(accounts, list): + accounts = [d.strip() for d in accounts.strip().split(',') if d] + + all_accounts = [] + for d in accounts: + if frappe.db.exists("Account", d): + lft, rgt = frappe.db.get_value("Account", d, ["lft", "rgt"]) + children = frappe.get_all("Account", filters={"lft": [">=", lft], "rgt": ["<=", rgt]}) + all_accounts += [c.name for c in children] + else: + frappe.throw(_("Account: {0} does not exist").format(d)) + + return list(set(all_accounts)) def get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries): data = [] diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py index 60e675f2f1f..48bd7308bca 100644 --- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py +++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py @@ -168,21 +168,24 @@ def get_columns(filters): "label": _("Income"), "fieldtype": "Currency", "options": "currency", - "width": 120 + "width": 305 + }, { "fieldname": "expense", "label": _("Expense"), "fieldtype": "Currency", "options": "currency", - "width": 120 + "width": 305 + }, { "fieldname": "gross_profit_loss", "label": _("Gross Profit / Loss"), "fieldtype": "Currency", "options": "currency", - "width": 120 + "width": 307 + } ] diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index e15715dccd8..6b9df41f54e 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -75,7 +75,8 @@ def get_invoice_and_tds_amount(supplier, account, company, from_date, to_date, f select voucher_no, credit from `tabGL Entry` where party in (%s) and credit > 0 - and company=%s and posting_date between %s and %s + and company=%s and is_cancelled = 0 + and posting_date between %s and %s """, (supplier, company, from_date, to_date), as_dict=1) supplier_credit_amount = flt(sum(d.credit for d in entries)) diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js index 344539eef6d..72de318a48c 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js @@ -54,6 +54,32 @@ frappe.query_reports["TDS Payable Monthly"] = { frappe.query_report.refresh(); } }, + { + "fieldname":"purchase_order", + "label": __("Purchase Order"), + "fieldtype": "Link", + "options": "Purchase Order", + "get_query": function() { + return { + "filters": { + "name": ["in", frappe.query_report.invoices] + } + } + }, + on_change: function() { + let supplier = frappe.query_report.get_filter_value('supplier'); + if(!supplier) return; // return if no supplier selected + + // filter invoices based on selected supplier + let invoices = []; + frappe.query_report.invoice_data.map(d => { + if(d.supplier==supplier) + invoices.push(d.name) + }); + frappe.query_report.invoices = invoices; + frappe.query_report.refresh(); + } + }, { "fieldname":"from_date", "label": __("From Date"), @@ -75,15 +101,17 @@ frappe.query_reports["TDS Payable Monthly"] = { onload: function(report) { // fetch all tds applied invoices frappe.call({ - "method": "erpnext.accounts.report.tds_payable_monthly.tds_payable_monthly.get_tds_invoices", + "method": "erpnext.accounts.report.tds_payable_monthly.tds_payable_monthly.get_tds_invoices_and_orders", callback: function(r) { let invoices = []; + r.message.map(d => { invoices.push(d.name); }); - report["invoice_data"] = r.message; + report["invoice_data"] = r.message.invoices; report["invoices"] = invoices; + } }); } diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index a9fb237a048..ceefa31cfa1 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -11,11 +11,14 @@ def execute(filters=None): validate_filters(filters) set_filters(filters) + # TDS payment entries + payment_entries = get_payment_entires(filters) + columns = get_columns(filters) - if not filters["invoices"]: + if not filters.get("invoices"): return columns, [] - res = get_result(filters) + res = get_result(filters, payment_entries) return columns, res @@ -27,8 +30,9 @@ def validate_filters(filters): def set_filters(filters): invoices = [] - if not filters["invoices"]: - filters["invoices"] = get_tds_invoices() + if not filters.get("invoices"): + filters["invoices"] = get_tds_invoices_and_orders() + if filters.supplier and filters.purchase_invoice: for d in filters["invoices"]: if d.name == filters.purchase_invoice and d.supplier == filters.supplier: @@ -41,13 +45,29 @@ def set_filters(filters): for d in filters["invoices"]: if d.name == filters.purchase_invoice: invoices.append(d) + elif filters.supplier and filters.purchase_order: + for d in filters.get("invoices"): + if d.name == filters.purchase_order and d.supplier == filters.supplier: + invoices.append(d) + elif filters.supplier and not filters.purchase_order: + for d in filters.get("invoices"): + if d.supplier == filters.supplier: + invoices.append(d) + elif filters.purchase_order and not filters.supplier: + for d in filters.get("invoices"): + if d.name == filters.purchase_order: + invoices.append(d) filters["invoices"] = invoices if invoices else filters["invoices"] filters.naming_series = frappe.db.get_single_value('Buying Settings', 'supp_master_name') -def get_result(filters): - supplier_map, tds_docs = get_supplier_map(filters) - gle_map = get_gle_map(filters) + #print(filters.get('invoices')) + +def get_result(filters, payment_entries): + supplier_map, tds_docs = get_supplier_map(filters, payment_entries) + documents = [d.get('name') for d in filters.get('invoices')] + [d.get('name') for d in payment_entries] + + gle_map = get_gle_map(filters, documents) out = [] for d in gle_map: @@ -62,10 +82,11 @@ def get_result(filters): for k in gle_map[d]: if k.party == supplier_map[d] and k.credit > 0: - total_amount_credited += k.credit - elif account_list and k.account == account and k.credit > 0: - tds_deducted = k.credit - total_amount_credited += k.credit + total_amount_credited += (k.credit - k.debit) + elif account_list and k.account == account and (k.credit - k.debit) > 0: + tds_deducted = (k.credit - k.debit) + total_amount_credited += (k.credit - k.debit) + voucher_type = k.voucher_type rate = [i.tax_withholding_rate for i in tds_doc.rates if i.fiscal_year == gle_map[d][0].fiscal_year] @@ -73,32 +94,36 @@ def get_result(filters): if rate and len(rate) > 0 and tds_deducted: rate = rate[0] - if getdate(filters.from_date) <= gle_map[d][0].posting_date \ - and getdate(filters.to_date) >= gle_map[d][0].posting_date: - row = [supplier.pan, supplier.name] + row = [supplier.pan, supplier.name] - if filters.naming_series == 'Naming Series': - row.append(supplier.supplier_name) + if filters.naming_series == 'Naming Series': + row.append(supplier.supplier_name) - row.extend([tds_doc.name, supplier.supplier_type, rate, total_amount_credited, - tds_deducted, gle_map[d][0].posting_date, "Purchase Invoice", d]) - out.append(row) + row.extend([tds_doc.name, supplier.supplier_type, rate, total_amount_credited, + tds_deducted, gle_map[d][0].posting_date, voucher_type, d]) + out.append(row) return out -def get_supplier_map(filters): +def get_supplier_map(filters, payment_entries): # create a supplier_map of the form {"purchase_invoice": {supplier_name, pan, tds_name}} # pre-fetch all distinct applicable tds docs supplier_map, tds_docs = {}, {} pan = "pan" if frappe.db.has_column("Supplier", "pan") else "tax_id" + supplier_list = [d.supplier for d in filters["invoices"]] + supplier_detail = frappe.db.get_all('Supplier', - {"name": ["in", [d.supplier for d in filters["invoices"]]]}, + {"name": ["in", supplier_list]}, ["tax_withholding_category", "name", pan+" as pan", "supplier_type", "supplier_name"]) for d in filters["invoices"]: supplier_map[d.get("name")] = [k for k in supplier_detail if k.name == d.get("supplier")][0] + for d in payment_entries: + supplier_map[d.get("name")] = [k for k in supplier_detail + if k.name == d.get("supplier")][0] + for d in supplier_detail: if d.get("tax_withholding_category") not in tds_docs: tds_docs[d.get("tax_withholding_category")] = \ @@ -106,13 +131,19 @@ def get_supplier_map(filters): return supplier_map, tds_docs -def get_gle_map(filters): +def get_gle_map(filters, documents): # create gle_map of the form # {"purchase_invoice": list of dict of all gle created for this invoice} gle_map = {} - gle = frappe.db.get_all('GL Entry',\ - {"voucher_no": ["in", [d.get("name") for d in filters["invoices"]]], 'is_cancelled': 0}, - ["fiscal_year", "credit", "debit", "account", "voucher_no", "posting_date"]) + + gle = frappe.db.get_all('GL Entry', + { + "voucher_no": ["in", documents], + 'is_cancelled': 0, + 'posting_date': ("between", [filters.get('from_date'), filters.get('to_date')]), + }, + ["fiscal_year", "credit", "debit", "account", "voucher_no", "posting_date", "voucher_type"], + ) for d in gle: if not d.voucher_no in gle_map: @@ -201,8 +232,26 @@ def get_columns(filters): return columns +def get_payment_entires(filters): + filter_dict = { + 'posting_date': ("between", [filters.get('from_date'), filters.get('to_date')]), + 'party_type': 'Supplier', + 'apply_tax_withholding_amount': 1 + } + + if filters.get('purchase_invoice') or filters.get('purchase_order'): + parent = frappe.db.get_all('Payment Entry Reference', + {'reference_name': ('in', [d.get('name') for d in filters.get('invoices')])}, ['parent']) + + filter_dict.update({'name': ('in', [d.get('parent') for d in parent])}) + + payment_entries = frappe.get_all('Payment Entry', fields=['name', 'party_name as supplier'], + filters=filter_dict) + + return payment_entries + @frappe.whitelist() -def get_tds_invoices(): +def get_tds_invoices_and_orders(): # fetch tds applicable supplier and fetch invoices for these suppliers suppliers = [d.name for d in frappe.db.get_list("Supplier", {"tax_withholding_category": ["!=", ""]}, ["name"])] @@ -210,7 +259,12 @@ def get_tds_invoices(): invoices = frappe.db.get_list("Purchase Invoice", {"supplier": ["in", suppliers]}, ["name", "supplier"]) + orders = frappe.db.get_list("Purchase Order", + {"supplier": ["in", suppliers]}, ["name", "supplier"]) + + invoices = invoices + orders invoices = [d for d in invoices if d.supplier] + frappe.cache().hset("invoices", frappe.session.user, invoices) return invoices diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 66a9b601257..1cdbd8d38a6 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -472,7 +472,8 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False): "total_amount": d.grand_total, "outstanding_amount": d.outstanding_amount, "allocated_amount": d.allocated_amount, - "exchange_rate": d.exchange_rate + "exchange_rate": d.exchange_rate if not d.exchange_gain_loss else payment_entry.get_exchange_rate(), + "exchange_gain_loss": d.exchange_gain_loss # only populated from invoice in case of advance allocation } if d.voucher_detail_no: @@ -498,12 +499,15 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False): payment_entry.set_amounts() if d.difference_amount and d.difference_account: - payment_entry.set_gain_or_loss(account_details={ + account_details = { 'account': d.difference_account, 'cost_center': payment_entry.cost_center or frappe.get_cached_value('Company', - payment_entry.company, "cost_center"), - 'amount': d.difference_amount - }) + payment_entry.company, "cost_center") + } + if d.difference_amount: + account_details['amount'] = d.difference_amount + + payment_entry.set_gain_or_loss(account_details=account_details) if not do_not_save: payment_entry.save(ignore_permissions=True) @@ -784,7 +788,7 @@ def get_children(doctype, parent, company, is_root=False): return acc def create_payment_gateway_account(gateway, payment_channel="Email"): - from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account + from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account company = frappe.db.get_value("Global Defaults", None, "default_company") if not company: diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json index b04b03ca79c..821fa4d2af4 100644 --- a/erpnext/accounts/workspace/accounting/accounting.json +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -707,6 +707,7 @@ "link_to": "GST Settings", "link_type": "DocType", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -717,6 +718,7 @@ "link_to": "GST HSN Code", "link_type": "DocType", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -727,6 +729,7 @@ "link_to": "GSTR-1", "link_type": "Report", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -737,6 +740,7 @@ "link_to": "GSTR-2", "link_type": "Report", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -747,6 +751,7 @@ "link_to": "GSTR 3B Report", "link_type": "DocType", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -757,6 +762,7 @@ "link_to": "GST Sales Register", "link_type": "Report", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -767,6 +773,7 @@ "link_to": "GST Purchase Register", "link_type": "Report", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -777,6 +784,7 @@ "link_to": "GST Itemised Sales Register", "link_type": "Report", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -787,6 +795,7 @@ "link_to": "GST Itemised Purchase Register", "link_type": "Report", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -797,6 +806,7 @@ "link_to": "C-Form", "link_type": "DocType", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -807,6 +817,7 @@ "link_to": "Lower Deduction Certificate", "link_type": "DocType", "onboard": 0, + "only_for": "India", "type": "Link" }, { @@ -1065,7 +1076,7 @@ "type": "Link" } ], - "modified": "2021-05-13 13:44:56.249888", + "modified": "2021-06-10 03:17:31.427945", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting", @@ -1120,4 +1131,4 @@ "type": "Dashboard" } ] -} +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 6f1bb28f370..922cc4a7b26 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -82,24 +82,46 @@ frappe.ui.form.on('Asset', { if (in_list(["Submitted", "Partially Depreciated", "Fully Depreciated"], frm.doc.status)) { frm.add_custom_button("Transfer Asset", function() { erpnext.asset.transfer_asset(frm); - }); + }, __("Manage")); frm.add_custom_button("Scrap Asset", function() { erpnext.asset.scrap_asset(frm); - }); + }, __("Manage")); frm.add_custom_button("Sell Asset", function() { frm.trigger("make_sales_invoice"); - }); + }, __("Manage")); } else if (frm.doc.status=='Scrapped') { frm.add_custom_button("Restore Asset", function() { erpnext.asset.restore_asset(frm); - }); + }, __("Manage")); + } + + if (frm.doc.maintenance_required && !frm.doc.maintenance_schedule) { + frm.add_custom_button(__("Maintain Asset"), function() { + frm.trigger("create_asset_maintenance"); + }, __("Manage")); + } + + frm.add_custom_button(__("Repair Asset"), function() { + frm.trigger("create_asset_repair"); + }, __("Manage")); + + if (frm.doc.status != 'Fully Depreciated') { + frm.add_custom_button(__("Adjust Asset Value"), function() { + frm.trigger("create_asset_adjustment"); + }, __("Manage")); + } + + if (!frm.doc.calculate_depreciation) { + frm.add_custom_button(__("Create Depreciation Entry"), function() { + frm.trigger("make_journal_entry"); + }, __("Manage")); } if (frm.doc.purchase_receipt || !frm.doc.is_existing_asset) { - frm.add_custom_button("General Ledger", function() { + frm.add_custom_button("View General Ledger", function() { frappe.route_options = { "voucher_no": frm.doc.name, "from_date": frm.doc.available_for_use_date, @@ -107,27 +129,9 @@ frappe.ui.form.on('Asset', { "company": frm.doc.company }; frappe.set_route("query-report", "General Ledger"); - }); + }, __("Manage")); } - if (frm.doc.maintenance_required && !frm.doc.maintenance_schedule) { - frm.add_custom_button(__("Asset Maintenance"), function() { - frm.trigger("create_asset_maintenance"); - }, __('Create')); - } - if (frm.doc.status != 'Fully Depreciated') { - frm.add_custom_button(__("Asset Value Adjustment"), function() { - frm.trigger("create_asset_adjustment"); - }, __('Create')); - } - - if (!frm.doc.calculate_depreciation) { - frm.add_custom_button(__("Depreciation Entry"), function() { - frm.trigger("make_journal_entry"); - }, __('Create')); - } - - frm.page.set_inner_btn_group_as_primary(__('Create')); frm.trigger("setup_chart"); } @@ -304,6 +308,20 @@ frappe.ui.form.on('Asset', { }) }, + create_asset_repair: function(frm) { + frappe.call({ + args: { + "asset": frm.doc.name, + "asset_name": frm.doc.asset_name + }, + method: "erpnext.assets.doctype.asset.asset.create_asset_repair", + callback: function(r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); + }, + create_asset_adjustment: function(frm) { frappe.call({ args: { diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 421b9a6c378..de060757e2e 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -502,7 +502,7 @@ "link_fieldname": "asset" } ], - "modified": "2021-01-22 12:38:59.091510", + "modified": "2021-06-24 14:58:51.097908", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 8799275fc4e..66f0bdcd588 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -168,17 +168,24 @@ class Asset(AccountsController): d.precision("rate_of_depreciation")) def make_depreciation_schedule(self): - if 'Manual' not in [d.depreciation_method for d in self.finance_books]: + if 'Manual' not in [d.depreciation_method for d in self.finance_books] and not self.schedules: self.schedules = [] - if self.get("schedules") or not self.available_for_use_date: + if not self.available_for_use_date: return for d in self.get('finance_books'): self.validate_asset_finance_books(d) + + start = self.clear_depreciation_schedule() - value_after_depreciation = (flt(self.gross_purchase_amount) - - flt(self.opening_accumulated_depreciation)) + # value_after_depreciation - current Asset value + if d.value_after_depreciation: + value_after_depreciation = (flt(d.value_after_depreciation) - + flt(self.opening_accumulated_depreciation)) + else: + value_after_depreciation = (flt(self.gross_purchase_amount) - + flt(self.opening_accumulated_depreciation)) d.value_after_depreciation = value_after_depreciation @@ -191,7 +198,7 @@ class Asset(AccountsController): number_of_pending_depreciations += 1 skip_row = False - for n in range(number_of_pending_depreciations): + for n in range(start, number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) if skip_row: continue @@ -216,11 +223,13 @@ class Asset(AccountsController): # For last row elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: - to_date = add_months(self.available_for_use_date, - n * cint(d.frequency_of_depreciation)) + if not self.flags.increase_in_asset_life: + # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission + self.to_date = add_months(self.available_for_use_date, + n * cint(d.frequency_of_depreciation)) depreciation_amount, days, months = self.get_pro_rata_amt(d, - depreciation_amount, schedule_date, to_date) + depreciation_amount, schedule_date, self.to_date) monthly_schedule_date = add_months(schedule_date, 1) @@ -284,10 +293,23 @@ class Asset(AccountsController): "finance_book_id": d.idx }) + # used when depreciation schedule needs to be modified due to increase in asset life + def clear_depreciation_schedule(self): + start = 0 + for n in range(len(self.schedules)): + if not self.schedules[n].journal_entry: + del self.schedules[n:] + start = n + break + return start + + + # if it returns True, depreciation_amount will not be equal for the first and last rows def check_is_pro_rata(self, row): has_pro_rata = False - days = date_diff(row.depreciation_start_date, self.available_for_use_date) + 1 + + # if frequency_of_depreciation is 12 months, total_days = 365 total_days = get_total_days(row.depreciation_start_date, row.frequency_of_depreciation) if days < total_days: @@ -346,11 +368,12 @@ class Asset(AccountsController): if d.finance_book_id not in finance_books: accumulated_depreciation = flt(self.opening_accumulated_depreciation) value_after_depreciation = flt(self.get_value_after_depreciation(d.finance_book_id)) - finance_books.append(d.finance_book_id) + finance_books.append(int(d.finance_book_id)) depreciation_amount = flt(d.depreciation_amount, d.precision("depreciation_amount")) value_after_depreciation -= flt(depreciation_amount) + # for the last row, if depreciation method = Straight Line if straight_line_idx and i == max(straight_line_idx) - 1: book = self.get('finance_books')[cint(d.finance_book_id) - 1] depreciation_amount += flt(value_after_depreciation - @@ -625,9 +648,18 @@ def create_asset_maintenance(asset, item_code, item_name, asset_category, compan }) return asset_maintenance +@frappe.whitelist() +def create_asset_repair(asset, asset_name): + asset_repair = frappe.new_doc("Asset Repair") + asset_repair.update({ + "asset": asset, + "asset_name": asset_name + }) + return asset_repair + @frappe.whitelist() def create_asset_adjustment(asset, asset_category, company): - asset_maintenance = frappe.new_doc("Asset Value Adjustment") + asset_maintenance = frappe.get_doc("Asset Value Adjustment") asset_maintenance.update({ "asset": asset, "company": company, @@ -757,8 +789,15 @@ def get_depreciation_amount(asset, depreciable_value, row): depreciation_left = flt(row.total_number_of_depreciations) - flt(asset.number_of_depreciations_booked) if row.depreciation_method in ("Straight Line", "Manual"): - depreciation_amount = (flt(row.value_after_depreciation) - - flt(row.expected_value_after_useful_life)) / depreciation_left + # if the Depreciation Schedule is being prepared for the first time + if not asset.flags.increase_in_asset_life: + depreciation_amount = (flt(row.value_after_depreciation) - + flt(row.expected_value_after_useful_life)) / depreciation_left + + # if the Depreciation Schedule is being modified after Asset Repair + else: + depreciation_amount = (flt(row.value_after_depreciation) - + flt(row.expected_value_after_useful_life)) / (date_diff(asset.to_date, asset.available_for_use_date) / 365) else: depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index 8f0afb42b2c..8fdbbf95d47 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -176,22 +176,34 @@ def restore_asset(asset_name): asset.set_status() -@frappe.whitelist() +def get_gl_entries_on_asset_regain(asset, selling_amount=0, finance_book=None): + fixed_asset_account, asset, depreciation_cost_center, accumulated_depr_account, accumulated_depr_amount, disposal_account, value_after_depreciation = \ + get_asset_details(asset, finance_book) + + gl_entries = [ + { + "account": fixed_asset_account, + "debit_in_account_currency": asset.gross_purchase_amount, + "debit": asset.gross_purchase_amount, + "cost_center": depreciation_cost_center + }, + { + "account": accumulated_depr_account, + "credit_in_account_currency": accumulated_depr_amount, + "credit": accumulated_depr_amount, + "cost_center": depreciation_cost_center + } + ] + + profit_amount = abs(flt(value_after_depreciation)) - abs(flt(selling_amount)) + if profit_amount: + get_profit_gl_entries(profit_amount, gl_entries, disposal_account, depreciation_cost_center) + + return gl_entries + def get_gl_entries_on_asset_disposal(asset, selling_amount=0, finance_book=None): - fixed_asset_account, accumulated_depr_account, depr_expense_account = get_depreciation_accounts(asset) - disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company) - depreciation_cost_center = asset.cost_center or depreciation_cost_center - - idx = 1 - if finance_book: - for d in asset.finance_books: - if d.finance_book == finance_book: - idx = d.idx - break - - value_after_depreciation = (asset.finance_books[idx - 1].value_after_depreciation - if asset.calculate_depreciation else asset.value_after_depreciation) - accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation) + fixed_asset_account, asset, depreciation_cost_center, accumulated_depr_account, accumulated_depr_amount, disposal_account, value_after_depreciation = \ + get_asset_details(asset, finance_book) gl_entries = [ { @@ -210,16 +222,37 @@ def get_gl_entries_on_asset_disposal(asset, selling_amount=0, finance_book=None) profit_amount = flt(selling_amount) - flt(value_after_depreciation) if profit_amount: - debit_or_credit = "debit" if profit_amount < 0 else "credit" - gl_entries.append({ - "account": disposal_account, - "cost_center": depreciation_cost_center, - debit_or_credit: abs(profit_amount), - debit_or_credit + "_in_account_currency": abs(profit_amount) - }) + get_profit_gl_entries(profit_amount, gl_entries, disposal_account, depreciation_cost_center) return gl_entries +def get_asset_details(asset, finance_book=None): + fixed_asset_account, accumulated_depr_account, depr_expense_account = get_depreciation_accounts(asset) + disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company) + depreciation_cost_center = asset.cost_center or depreciation_cost_center + + idx = 1 + if finance_book: + for d in asset.finance_books: + if d.finance_book == finance_book: + idx = d.idx + break + + value_after_depreciation = (asset.finance_books[idx - 1].value_after_depreciation + if asset.calculate_depreciation else asset.value_after_depreciation) + accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation) + + return fixed_asset_account, asset, depreciation_cost_center, accumulated_depr_account, accumulated_depr_amount, disposal_account, value_after_depreciation + +def get_profit_gl_entries(profit_amount, gl_entries, disposal_account, depreciation_cost_center): + debit_or_credit = "debit" if profit_amount < 0 else "credit" + gl_entries.append({ + "account": disposal_account, + "cost_center": depreciation_cost_center, + debit_or_credit: abs(profit_amount), + debit_or_credit + "_in_account_currency": abs(profit_amount) + }) + @frappe.whitelist() def get_disposal_account_and_cost_center(company): disposal_account, depreciation_cost_center = frappe.get_cached_value('Company', company, diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 8845f24d104..59fbe3b0301 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -125,7 +125,6 @@ class TestAsset(unittest.TestCase): "frequency_of_depreciation": 12, "depreciation_start_date": "2030-12-31" }) - asset.insert() self.assertEqual(asset.status, "Draft") asset.save() expected_schedules = [ @@ -154,9 +153,8 @@ class TestAsset(unittest.TestCase): "frequency_of_depreciation": 12, "depreciation_start_date": '2030-12-31' }) - asset.insert() - self.assertEqual(asset.status, "Draft") asset.save() + self.assertEqual(asset.status, "Draft") expected_schedules = [ ['2030-12-31', 66667.00, 66667.00], @@ -185,7 +183,7 @@ class TestAsset(unittest.TestCase): "frequency_of_depreciation": 12, "depreciation_start_date": "2030-12-31" }) - asset.insert() + asset.save() self.assertEqual(asset.status, "Draft") expected_schedules = [ @@ -216,7 +214,6 @@ class TestAsset(unittest.TestCase): "depreciation_start_date": "2030-12-31" }) - asset.insert() asset.save() expected_schedules = [ @@ -247,7 +244,6 @@ class TestAsset(unittest.TestCase): "frequency_of_depreciation": 10, "depreciation_start_date": "2020-12-31" }) - asset.insert() asset.submit() asset.load_from_db() self.assertEqual(asset.status, "Submitted") @@ -350,7 +346,6 @@ class TestAsset(unittest.TestCase): "frequency_of_depreciation": 10, "depreciation_start_date": "2020-12-31" }) - asset.insert() asset.submit() post_depreciation_entries(date="2021-01-01") @@ -380,7 +375,6 @@ class TestAsset(unittest.TestCase): "total_number_of_depreciations": 10, "frequency_of_depreciation": 1 }) - asset.insert() asset.submit() post_depreciation_entries(date=add_months('2020-01-01', 4)) @@ -424,7 +418,6 @@ class TestAsset(unittest.TestCase): "frequency_of_depreciation": 10, "depreciation_start_date": "2020-12-31" }) - asset.insert() asset.submit() post_depreciation_entries(date="2021-01-01") @@ -468,7 +461,7 @@ class TestAsset(unittest.TestCase): "total_number_of_depreciations": 3, "frequency_of_depreciation": 10 }) - asset.insert() + asset.save() accumulated_depreciation_after_full_schedule = \ max(d.accumulated_depreciation_amount for d in asset.get("schedules")) @@ -699,7 +692,7 @@ def create_asset(**args): "item_code": args.item_code or "Macbook Pro", "company": args.company or"_Test Company", "purchase_date": "2015-01-01", - "calculate_depreciation": 0, + "calculate_depreciation": args.calculate_depreciation or 0, "gross_purchase_amount": 100000, "purchase_receipt_amount": 100000, "expected_value_after_useful_life": 10000, @@ -707,9 +700,16 @@ def create_asset(**args): "available_for_use_date": "2020-06-06", "location": "Test Location", "asset_owner": "Company", - "is_existing_asset": args.is_existing_asset or 0 + "is_existing_asset": 1 }) + if asset.calculate_depreciation: + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 5 + }) + try: asset.save() except frappe.DuplicateEntryError: diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json index d9b7b695f7f..e5a5f194c1b 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json @@ -67,7 +67,6 @@ { "fieldname": "value_after_depreciation", "fieldtype": "Currency", - "hidden": 1, "label": "Value After Depreciation", "no_copy": 1, "options": "Company:company:default_currency", @@ -85,7 +84,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-11-05 16:30:09.213479", + "modified": "2021-06-17 12:59:05.743683", "modified_by": "Administrator", "module": "Assets", "name": "Asset Finance Book", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 4ba2b4474a9..1cebfff66e5 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -2,6 +2,45 @@ // For license information, please see license.txt frappe.ui.form.on('Asset Repair', { + setup: function(frm) { + frm.fields_dict.cost_center.get_query = function(doc) { + return { + filters: { + 'is_group': 0, + 'company': doc.company + } + }; + }; + + frm.fields_dict.project.get_query = function(doc) { + return { + filters: { + 'company': doc.company + } + }; + }; + + frm.fields_dict.warehouse.get_query = function(doc) { + return { + filters: { + 'is_group': 0, + 'company': doc.company + } + }; + }; + }, + + refresh: function(frm) { + if (frm.doc.docstatus) { + frm.add_custom_button("View General Ledger", function() { + frappe.route_options = { + "voucher_no": frm.doc.name + }; + frappe.set_route("query-report", "General Ledger"); + }); + } + }, + repair_status: (frm) => { if (frm.doc.completion_date && frm.doc.repair_status == "Completed") { frappe.call ({ @@ -17,5 +56,16 @@ frappe.ui.form.on('Asset Repair', { } }); } + + if (frm.doc.repair_status == "Completed") { + frm.set_value('completion_date', frappe.datetime.now_datetime()); + } } }); + +frappe.ui.form.on('Asset Repair Consumed Item', { + consumed_quantity: function(frm, cdt, cdn) { + var row = locals[cdt][cdn]; + frappe.model.set_value(cdt, cdn, 'total_value', row.consumed_quantity * row.valuation_rate); + }, +}); \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index d338fc0fb79..ba3189887ce 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -7,38 +7,43 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "naming_series", - "asset_name", + "asset", + "company", "column_break_2", - "item_code", - "item_name", + "asset_name", + "naming_series", "section_break_5", "failure_date", - "assign_to", - "assign_to_name", + "repair_status", "column_break_6", "completion_date", - "repair_status", + "accounting_dimensions_section", + "cost_center", + "column_break_14", + "project", + "accounting_details", "repair_cost", + "capitalize_repair_cost", + "stock_consumption", + "column_break_8", + "purchase_invoice", + "stock_consumption_details_section", + "warehouse", + "stock_items", + "total_repair_cost", + "stock_entry", + "asset_depreciation_details_section", + "increase_in_asset_life", "section_break_9", "description", "column_break_9", "actions_performed", - "section_break_17", + "section_break_23", "downtime", "column_break_19", "amended_from" ], "fields": [ - { - "columns": 1, - "fieldname": "asset_name", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Asset", - "options": "Asset", - "reqd": 1 - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -50,18 +55,6 @@ "fieldname": "column_break_2", "fieldtype": "Column Break" }, - { - "fetch_from": "asset_name.item_code", - "fieldname": "item_code", - "fieldtype": "Read Only", - "label": "Item Code" - }, - { - "fetch_from": "asset_name.item_name", - "fieldname": "item_name", - "fieldtype": "Read Only", - "label": "Item Name" - }, { "fieldname": "section_break_5", "fieldtype": "Section Break", @@ -74,33 +67,20 @@ "label": "Failure Date", "reqd": 1 }, - { - "allow_on_submit": 1, - "fieldname": "assign_to", - "fieldtype": "Link", - "label": "Assign To", - "options": "User" - }, - { - "allow_on_submit": 1, - "fetch_from": "assign_to.full_name", - "fieldname": "assign_to_name", - "fieldtype": "Read Only", - "label": "Assign To Name" - }, { "fieldname": "column_break_6", "fieldtype": "Column Break" }, { - "allow_on_submit": 1, + "depends_on": "eval:!doc.__islocal", "fieldname": "completion_date", "fieldtype": "Datetime", - "label": "Completion Date" + "label": "Completion Date", + "no_copy": 1 }, { - "allow_on_submit": 1, "default": "Pending", + "depends_on": "eval:!doc.__islocal", "fieldname": "repair_status", "fieldtype": "Select", "label": "Repair Status", @@ -116,25 +96,18 @@ { "fieldname": "description", "fieldtype": "Long Text", - "label": "Error Description", - "reqd": 1 + "label": "Error Description" }, { "fieldname": "column_break_9", "fieldtype": "Column Break" }, { - "allow_on_submit": 1, "fieldname": "actions_performed", "fieldtype": "Long Text", "label": "Actions performed" }, { - "fieldname": "section_break_17", - "fieldtype": "Section Break" - }, - { - "allow_on_submit": 1, "fieldname": "downtime", "fieldtype": "Data", "in_list_view": 1, @@ -146,7 +119,7 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, + "default": "0", "fieldname": "repair_cost", "fieldtype": "Currency", "label": "Repair Cost" @@ -159,12 +132,139 @@ "options": "Asset Repair", "print_hide": 1, "read_only": 1 + }, + { + "columns": 1, + "fieldname": "asset", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Asset", + "options": "Asset", + "reqd": 1 + }, + { + "fetch_from": "asset.asset_name", + "fieldname": "asset_name", + "fieldtype": "Read Only", + "label": "Asset Name" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.__islocal", + "fieldname": "capitalize_repair_cost", + "fieldtype": "Check", + "label": "Capitalize Repair Cost" + }, + { + "fieldname": "accounting_details", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fieldname": "stock_items", + "fieldtype": "Table", + "label": "Stock Items", + "mandatory_depends_on": "stock_consumption", + "options": "Asset Repair Consumed Item" + }, + { + "fieldname": "section_break_23", + "fieldtype": "Section Break" + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.__islocal", + "fieldname": "stock_consumption", + "fieldtype": "Check", + "label": "Stock Consumed During Repair" + }, + { + "depends_on": "stock_consumption", + "fieldname": "stock_consumption_details_section", + "fieldtype": "Section Break", + "label": "Stock Consumption Details" + }, + { + "depends_on": "eval: doc.stock_consumption && doc.total_repair_cost > 0", + "description": "Sum of Repair Cost and Value of Consumed Stock Items.", + "fieldname": "total_repair_cost", + "fieldtype": "Currency", + "label": "Total Repair Cost", + "read_only": 1 + }, + { + "depends_on": "stock_consumption", + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse" + }, + { + "depends_on": "capitalize_repair_cost", + "fieldname": "asset_depreciation_details_section", + "fieldtype": "Section Break", + "label": "Asset Depreciation Details" + }, + { + "fieldname": "increase_in_asset_life", + "fieldtype": "Int", + "label": "Increase In Asset Life(Months)", + "no_copy": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "purchase_invoice", + "fieldtype": "Link", + "label": "Purchase Invoice", + "mandatory_depends_on": "eval: doc.repair_status == 'Completed' && doc.repair_cost > 0", + "no_copy": 1, + "options": "Purchase Invoice" + }, + { + "fetch_from": "asset.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "stock_entry", + "fieldtype": "Link", + "label": "Stock Entry", + "options": "Stock Entry", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-01-22 15:08:12.495850", + "modified": "2021-06-25 13:14:38.307723", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", @@ -203,6 +303,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "title_field": "asset_name", "track_changes": 1, "track_seen": 1 } \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 049b931b5e8..d32fdf7054f 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -5,16 +5,252 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import time_diff_in_hours -from frappe.model.document import Document +from frappe.utils import time_diff_in_hours, getdate, add_months, flt, cint +from erpnext.accounts.general_ledger import make_gl_entries +from erpnext.assets.doctype.asset.asset import get_asset_account +from erpnext.controllers.accounts_controller import AccountsController -class AssetRepair(Document): +class AssetRepair(AccountsController): def validate(self): - if self.repair_status == "Completed" and not self.completion_date: - frappe.throw(_("Please select Completion Date for Completed Repair")) + self.asset_doc = frappe.get_doc('Asset', self.asset) + self.update_status() + if self.get('stock_items'): + self.set_total_value() + self.calculate_total_repair_cost() + + def update_status(self): + if self.repair_status == 'Pending': + frappe.db.set_value('Asset', self.asset, 'status', 'Out of Order') + else: + self.asset_doc.set_status() + + def set_total_value(self): + for item in self.get('stock_items'): + item.total_value = flt(item.valuation_rate) * flt(item.consumed_quantity) + + def calculate_total_repair_cost(self): + self.total_repair_cost = flt(self.repair_cost) + + total_value_of_stock_consumed = self.get_total_value_of_stock_consumed() + self.total_repair_cost += total_value_of_stock_consumed + + def before_submit(self): + self.check_repair_status() + + if self.get('stock_consumption') or self.get('capitalize_repair_cost'): + self.increase_asset_value() + if self.get('stock_consumption'): + self.check_for_stock_items_and_warehouse() + self.decrease_stock_quantity() + if self.get('capitalize_repair_cost'): + self.make_gl_entries() + if frappe.db.get_value('Asset', self.asset, 'calculate_depreciation') and self.increase_in_asset_life: + self.modify_depreciation_schedule() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + self.asset_doc.save() + + def before_cancel(self): + self.asset_doc = frappe.get_doc('Asset', self.asset) + + if self.get('stock_consumption') or self.get('capitalize_repair_cost'): + self.decrease_asset_value() + if self.get('stock_consumption'): + self.increase_stock_quantity() + if self.get('capitalize_repair_cost'): + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') + self.make_gl_entries(cancel=True) + if frappe.db.get_value('Asset', self.asset, 'calculate_depreciation') and self.increase_in_asset_life: + self.revert_depreciation_schedule_on_cancellation() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + self.asset_doc.save() + + def check_repair_status(self): + if self.repair_status == "Pending": + frappe.throw(_("Please update Repair Status.")) + + def check_for_stock_items_and_warehouse(self): + if not self.get('stock_items'): + frappe.throw(_("Please enter Stock Items consumed during the Repair."), title=_("Missing Items")) + if not self.warehouse: + frappe.throw(_("Please enter Warehouse from which Stock Items consumed during the Repair were taken."), title=_("Missing Warehouse")) + + def increase_asset_value(self): + total_value_of_stock_consumed = self.get_total_value_of_stock_consumed() + + if self.asset_doc.calculate_depreciation: + for row in self.asset_doc.finance_books: + row.value_after_depreciation += total_value_of_stock_consumed + + if self.capitalize_repair_cost: + row.value_after_depreciation += self.repair_cost + + def decrease_asset_value(self): + total_value_of_stock_consumed = self.get_total_value_of_stock_consumed() + + if self.asset_doc.calculate_depreciation: + for row in self.asset_doc.finance_books: + row.value_after_depreciation -= total_value_of_stock_consumed + + if self.capitalize_repair_cost: + row.value_after_depreciation -= self.repair_cost + + def get_total_value_of_stock_consumed(self): + total_value_of_stock_consumed = 0 + if self.get('stock_consumption'): + for item in self.get('stock_items'): + total_value_of_stock_consumed += item.total_value + + return total_value_of_stock_consumed + + def decrease_stock_quantity(self): + stock_entry = frappe.get_doc({ + "doctype": "Stock Entry", + "stock_entry_type": "Material Issue", + "company": self.company + }) + + for stock_item in self.get('stock_items'): + stock_entry.append('items', { + "s_warehouse": self.warehouse, + "item_code": stock_item.item, + "qty": stock_item.consumed_quantity, + "basic_rate": stock_item.valuation_rate + }) + + stock_entry.insert() + stock_entry.submit() + + self.db_set('stock_entry', stock_entry.name) + + def increase_stock_quantity(self): + stock_entry = frappe.get_doc('Stock Entry', self.stock_entry) + stock_entry.flags.ignore_links = True + stock_entry.cancel() + + def make_gl_entries(self, cancel=False): + if flt(self.repair_cost) > 0: + gl_entries = self.get_gl_entries() + make_gl_entries(gl_entries, cancel) + + def get_gl_entries(self): + gl_entries = [] + repair_and_maintenance_account = frappe.db.get_value('Company', self.company, 'repair_and_maintenance_account') + fixed_asset_account = get_asset_account("fixed_asset_account", asset=self.asset, company=self.company) + expense_account = frappe.get_doc('Purchase Invoice', self.purchase_invoice).items[0].expense_account + + gl_entries.append( + self.get_gl_dict({ + "account": expense_account, + "credit": self.repair_cost, + "credit_in_account_currency": self.repair_cost, + "against": repair_and_maintenance_account, + "voucher_type": self.doctype, + "voucher_no": self.name, + "cost_center": self.cost_center, + "posting_date": getdate(), + "company": self.company + }, item=self) + ) + + if self.get('stock_consumption'): + # creating GL Entries for each row in Stock Items based on the Stock Entry created for it + stock_entry = frappe.get_doc('Stock Entry', self.stock_entry) + for item in stock_entry.items: + gl_entries.append( + self.get_gl_dict({ + "account": item.expense_account, + "credit": item.amount, + "credit_in_account_currency": item.amount, + "against": repair_and_maintenance_account, + "voucher_type": self.doctype, + "voucher_no": self.name, + "cost_center": self.cost_center, + "posting_date": getdate(), + "company": self.company + }, item=self) + ) + + gl_entries.append( + self.get_gl_dict({ + "account": fixed_asset_account, + "debit": self.total_repair_cost, + "debit_in_account_currency": self.total_repair_cost, + "against": expense_account, + "voucher_type": self.doctype, + "voucher_no": self.name, + "cost_center": self.cost_center, + "posting_date": getdate(), + "against_voucher_type": "Purchase Invoice", + "against_voucher": self.purchase_invoice, + "company": self.company + }, item=self) + ) + + return gl_entries + + def modify_depreciation_schedule(self): + for row in self.asset_doc.finance_books: + row.total_number_of_depreciations += self.increase_in_asset_life/row.frequency_of_depreciation + + self.asset_doc.flags.increase_in_asset_life = False + extra_months = self.increase_in_asset_life % row.frequency_of_depreciation + if extra_months != 0: + self.calculate_last_schedule_date(self.asset_doc, row, extra_months) + + # to help modify depreciation schedule when increase_in_asset_life is not a multiple of frequency_of_depreciation + def calculate_last_schedule_date(self, asset, row, extra_months): + asset.flags.increase_in_asset_life = True + number_of_pending_depreciations = cint(row.total_number_of_depreciations) - \ + cint(asset.number_of_depreciations_booked) + + # the Schedule Date in the final row of the old Depreciation Schedule + last_schedule_date = asset.schedules[len(asset.schedules)-1].schedule_date + + # the Schedule Date in the final row of the new Depreciation Schedule + asset.to_date = add_months(last_schedule_date, extra_months) + + # the latest possible date at which the depreciation can occur, without increasing the Total Number of Depreciations + # if depreciations happen yearly and the Depreciation Posting Date is 01-01-2020, this could be 01-01-2021, 01-01-2022... + schedule_date = add_months(row.depreciation_start_date, + number_of_pending_depreciations * cint(row.frequency_of_depreciation)) + + if asset.to_date > schedule_date: + row.total_number_of_depreciations += 1 + + def revert_depreciation_schedule_on_cancellation(self): + for row in self.asset_doc.finance_books: + row.total_number_of_depreciations -= self.increase_in_asset_life/row.frequency_of_depreciation + + self.asset_doc.flags.increase_in_asset_life = False + extra_months = self.increase_in_asset_life % row.frequency_of_depreciation + if extra_months != 0: + self.calculate_last_schedule_date_before_modification(self.asset_doc, row, extra_months) + + def calculate_last_schedule_date_before_modification(self, asset, row, extra_months): + asset.flags.increase_in_asset_life = True + number_of_pending_depreciations = cint(row.total_number_of_depreciations) - \ + cint(asset.number_of_depreciations_booked) + + # the Schedule Date in the final row of the modified Depreciation Schedule + last_schedule_date = asset.schedules[len(asset.schedules)-1].schedule_date + + # the Schedule Date in the final row of the original Depreciation Schedule + asset.to_date = add_months(last_schedule_date, -extra_months) + + # the latest possible date at which the depreciation can occur, without decreasing the Total Number of Depreciations + # if depreciations happen yearly and the Depreciation Posting Date is 01-01-2020, this could be 01-01-2021, 01-01-2022... + schedule_date = add_months(row.depreciation_start_date, + (number_of_pending_depreciations - 1) * cint(row.frequency_of_depreciation)) + + if asset.to_date < schedule_date: + row.total_number_of_depreciations -= 1 @frappe.whitelist() def get_downtime(failure_date, completion_date): downtime = time_diff_in_hours(completion_date, failure_date) - return round(downtime, 2) \ No newline at end of file + return round(downtime, 2) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 3d325a9683c..30bbb37851e 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -2,8 +2,167 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt from __future__ import unicode_literals - +import frappe +from frappe.utils import nowdate, flt import unittest +from erpnext.assets.doctype.asset.test_asset import create_asset_data, create_asset, set_depreciation_settings_in_company class TestAssetRepair(unittest.TestCase): - pass + def setUp(self): + set_depreciation_settings_in_company() + create_asset_data() + frappe.db.sql("delete from `tabTax Rule`") + + def test_update_status(self): + asset = create_asset() + initial_status = asset.status + asset_repair = create_asset_repair(asset = asset) + + if asset_repair.repair_status == "Pending": + asset.reload() + self.assertEqual(asset.status, "Out of Order") + + asset_repair.repair_status = "Completed" + asset_repair.save() + asset_status = frappe.db.get_value("Asset", asset_repair.asset, "status") + self.assertEqual(asset_status, initial_status) + + def test_stock_item_total_value(self): + asset_repair = create_asset_repair(stock_consumption = 1) + + for item in asset_repair.stock_items: + total_value = flt(item.valuation_rate) * flt(item.consumed_quantity) + self.assertEqual(item.total_value, total_value) + + def test_total_repair_cost(self): + asset_repair = create_asset_repair(stock_consumption = 1) + + total_repair_cost = asset_repair.repair_cost + self.assertEqual(total_repair_cost, asset_repair.repair_cost) + for item in asset_repair.stock_items: + total_repair_cost += item.total_value + + self.assertEqual(total_repair_cost, asset_repair.total_repair_cost) + + def test_repair_status_after_submit(self): + asset_repair = create_asset_repair(submit = 1) + self.assertNotEqual(asset_repair.repair_status, "Pending") + + def test_stock_items(self): + asset_repair = create_asset_repair(stock_consumption = 1) + self.assertTrue(asset_repair.stock_consumption) + self.assertTrue(asset_repair.stock_items) + + def test_warehouse(self): + asset_repair = create_asset_repair(stock_consumption = 1) + self.assertTrue(asset_repair.stock_consumption) + self.assertTrue(asset_repair.warehouse) + + def test_decrease_stock_quantity(self): + asset_repair = create_asset_repair(stock_consumption = 1, submit = 1) + stock_entry = frappe.get_last_doc('Stock Entry') + + self.assertEqual(stock_entry.stock_entry_type, "Material Issue") + self.assertEqual(stock_entry.items[0].s_warehouse, asset_repair.warehouse) + self.assertEqual(stock_entry.items[0].item_code, asset_repair.stock_items[0].item) + self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity) + + def test_increase_in_asset_value_due_to_stock_consumption(self): + asset = create_asset(calculate_depreciation = 1) + initial_asset_value = get_asset_value(asset) + asset_repair = create_asset_repair(asset= asset, stock_consumption = 1, submit = 1) + asset.reload() + + increase_in_asset_value = get_asset_value(asset) - initial_asset_value + self.assertEqual(asset_repair.stock_items[0].total_value, increase_in_asset_value) + + def test_increase_in_asset_value_due_to_repair_cost_capitalisation(self): + asset = create_asset(calculate_depreciation = 1) + initial_asset_value = get_asset_value(asset) + asset_repair = create_asset_repair(asset= asset, capitalize_repair_cost = 1, submit = 1) + asset.reload() + + increase_in_asset_value = get_asset_value(asset) - initial_asset_value + self.assertEqual(asset_repair.repair_cost, increase_in_asset_value) + + def test_purchase_invoice(self): + asset_repair = create_asset_repair(capitalize_repair_cost = 1, submit = 1) + self.assertTrue(asset_repair.purchase_invoice) + + def test_gl_entries(self): + asset_repair = create_asset_repair(capitalize_repair_cost = 1, submit = 1) + gl_entry = frappe.get_last_doc('GL Entry') + self.assertEqual(asset_repair.name, gl_entry.voucher_no) + + def test_increase_in_asset_life(self): + asset = create_asset(calculate_depreciation = 1) + initial_num_of_depreciations = num_of_depreciations(asset) + create_asset_repair(asset= asset, capitalize_repair_cost = 1, submit = 1) + asset.reload() + + self.assertEqual((initial_num_of_depreciations + 1), num_of_depreciations(asset)) + self.assertEqual(asset.schedules[-1].accumulated_depreciation_amount, asset.finance_books[0].value_after_depreciation) + +def get_asset_value(asset): + return asset.finance_books[0].value_after_depreciation + +def num_of_depreciations(asset): + return asset.finance_books[0].total_number_of_depreciations + +def create_asset_repair(**args): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + args = frappe._dict(args) + + if args.asset: + asset = args.asset + else: + asset = create_asset(is_existing_asset = 1) + asset_repair = frappe.new_doc("Asset Repair") + asset_repair.update({ + "asset": asset.name, + "asset_name": asset.asset_name, + "failure_date": nowdate(), + "description": "Test Description", + "repair_cost": 0, + "company": asset.company + }) + + if args.stock_consumption: + asset_repair.stock_consumption = 1 + asset_repair.warehouse = create_warehouse("Test Warehouse", company = asset.company) + asset_repair.append("stock_items", { + "item": args.item or args.item_code or "_Test Item", + "valuation_rate": args.rate if args.get("rate") is not None else 100, + "consumed_quantity": args.qty or 1 + }) + + asset_repair.insert(ignore_if_duplicate=True) + + if args.submit: + asset_repair.repair_status = "Completed" + asset_repair.cost_center = "_Test Cost Center - _TC" + + if args.stock_consumption: + stock_entry = frappe.get_doc({ + "doctype": "Stock Entry", + "stock_entry_type": "Material Receipt", + "company": asset.company + }) + stock_entry.append('items', { + "t_warehouse": asset_repair.warehouse, + "item_code": asset_repair.stock_items[0].item, + "qty": asset_repair.stock_items[0].consumed_quantity + }) + stock_entry.submit() + + if args.capitalize_repair_cost: + asset_repair.capitalize_repair_cost = 1 + asset_repair.repair_cost = 1000 + if asset.calculate_depreciation: + asset_repair.increase_in_asset_life = 12 + asset_repair.purchase_invoice = make_purchase_invoice().name + + asset_repair.submit() + return asset_repair \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/__init__.py b/erpnext/assets/doctype/asset_repair_consumed_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json new file mode 100644 index 00000000000..528f0ec986a --- /dev/null +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json @@ -0,0 +1,55 @@ +{ + "actions": [], + "creation": "2021-05-12 02:41:54.161024", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item", + "valuation_rate", + "consumed_quantity", + "total_value" + ], + "fields": [ + { + "fieldname": "item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item", + "options": "Item" + }, + { + "fetch_from": "item.valuation_rate", + "fieldname": "valuation_rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Valuation Rate", + "read_only": 1 + }, + { + "fieldname": "consumed_quantity", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumed Quantity" + }, + { + "fieldname": "total_value", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Total Value", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-05-12 03:19:55.006300", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Repair Consumed Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py new file mode 100644 index 00000000000..fa22a5712f4 --- /dev/null +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class AssetRepairConsumedItem(Document): + pass diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 630a1dc8cd5..b9c77d59b18 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -9,13 +9,14 @@ "supp_master_name", "supplier_group", "buying_price_list", + "maintain_same_rate_action", + "role_to_override_stop_action", "column_break_3", "po_required", "pr_required", "maintain_same_rate", - "maintain_same_rate_action", - "role_to_override_stop_action", "allow_multiple_items", + "bill_for_rejected_quantity_in_purchase_invoice", "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", @@ -108,6 +109,13 @@ "fieldtype": "Link", "label": "Role Allowed to Override Stop Action", "options": "Role" + }, + { + "default": "1", + "description": "If checked, Rejected Quantity will be included while making Purchase Invoice from Purchase Receipt.", + "fieldname": "bill_for_rejected_quantity_in_purchase_invoice", + "fieldtype": "Check", + "label": "Bill for Rejected Quantity in Purchase Invoice" } ], "icon": "fa fa-cog", @@ -115,7 +123,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-04-04 20:01:44.087066", + "modified": "2021-06-24 10:38:28.934525", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 384bbc53857..521432d296b 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -45,6 +45,47 @@ frappe.ui.form.on("Purchase Order", { }); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + }, + + apply_tds: function(frm) { + if (!frm.doc.apply_tds) { + frm.set_value("tax_withholding_category", ''); + } else { + frm.set_value("tax_withholding_category", frm.supplier_tds); + } + }, + + refresh: function(frm) { + frm.trigger('get_materials_from_supplier'); + }, + + get_materials_from_supplier: function(frm) { + let po_details = []; + + if (frm.doc.supplied_items && (frm.doc.per_received == 100 || frm.doc.status === 'Closed')) { + frm.doc.supplied_items.forEach(d => { + if (d.total_supplied_qty && d.total_supplied_qty != d.consumed_qty) { + po_details.push(d.name) + } + }); + } + + if (po_details && po_details.length) { + frm.add_custom_button(__('Return of Components'), () => { + frm.call({ + method: 'erpnext.buying.doctype.purchase_order.purchase_order.get_materials_from_supplier', + freeze: true, + freeze_message: __('Creating Stock Entry'), + args: { purchase_order: frm.doc.name, po_details: po_details }, + callback: function(r) { + if (r && r.message) { + const doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + } + } + }); + }, __('Create')); + } } }); @@ -209,7 +250,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e } has_unsupplied_items() { - return this.frm.doc['supplied_items'].some(item => item.required_qty != item.supplied_qty) + return this.frm.doc['supplied_items'].some(item => item.required_qty > item.supplied_qty); } make_stock_entry() { @@ -313,7 +354,8 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e if(me.values) { me.values.sub_con_rm_items.map((row,i) => { if (!row.item_code || !row.rm_item_code || !row.warehouse || !row.qty || row.qty === 0) { - frappe.throw(__("Item Code, warehouse, quantity are required on row {0}", [i+1])); + let row_id = i+1; + frappe.throw(__("Item Code, warehouse and quantity are required on row {0}", [row_id])); } }) me._make_rm_stock_entry(me.dialog.fields_dict.sub_con_rm_items.grid.get_selected_children()) @@ -504,12 +546,14 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e ], primary_action: function() { var data = d.get_values(); + let reason_for_hold = 'Reason for hold: ' + data.reason_for_hold; + frappe.call({ method: "frappe.desk.form.utils.add_comment", args: { reference_doctype: me.frm.doctype, reference_name: me.frm.docname, - content: __('Reason for hold: ')+data.reason_for_hold, + content: __(reason_for_hold), comment_email: frappe.session.user, comment_by: frappe.session.user_fullname }, diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index ee2beea67f9..bb0ad60cabc 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -14,6 +14,8 @@ "supplier", "get_items_from_open_material_requests", "supplier_name", + "apply_tds", + "tax_withholding_category", "column_break1", "company", "transaction_date", @@ -142,7 +144,9 @@ { "fieldname": "supplier_section", "fieldtype": "Section Break", - "options": "fa fa-user" + "options": "fa fa-user", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, @@ -152,7 +156,9 @@ "hidden": 1, "label": "Title", "no_copy": 1, - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "naming_series", @@ -164,7 +170,9 @@ "options": "PUR-ORD-.YYYY.-", "print_hide": 1, "reqd": 1, - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "bold": 1, @@ -178,14 +186,18 @@ "options": "Supplier", "print_hide": 1, "reqd": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.supplier && doc.docstatus===0 && (!(doc.items && doc.items.length) || (doc.items.length==1 && !doc.items[0].item_code))", "description": "Fetch items based on Default Supplier.", "fieldname": "get_items_from_open_material_requests", "fieldtype": "Button", - "label": "Get Items from Open Material Requests" + "label": "Get Items from Open Material Requests", + "show_days": 1, + "show_seconds": 1 }, { "bold": 1, @@ -194,7 +206,9 @@ "fieldtype": "Data", "in_global_search": 1, "label": "Supplier Name", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "company", @@ -206,13 +220,17 @@ "options": "Company", "print_hide": 1, "remember_last_selected_value": 1, - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break1", "fieldtype": "Column Break", "oldfieldtype": "Column Break", "print_width": "50%", + "show_days": 1, + "show_seconds": 1, "width": "50%" }, { @@ -224,27 +242,35 @@ "oldfieldname": "transaction_date", "oldfieldtype": "Date", "reqd": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "schedule_date", "fieldtype": "Date", - "label": "Required By" + "label": "Required By", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "depends_on": "eval:doc.docstatus===1", "fieldname": "order_confirmation_no", "fieldtype": "Data", - "label": "Order Confirmation No" + "label": "Order Confirmation No", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "depends_on": "eval:doc.order_confirmation_no", "fieldname": "order_confirmation_date", "fieldtype": "Date", - "label": "Order Confirmation Date" + "label": "Order Confirmation Date", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "amended_from", @@ -256,19 +282,25 @@ "oldfieldtype": "Data", "options": "Purchase Order", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "drop_ship", "fieldtype": "Section Break", - "label": "Drop Ship" + "label": "Drop Ship", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "customer", "fieldtype": "Link", "label": "Customer", "options": "Customer", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "bold": 1, @@ -276,31 +308,41 @@ "fieldtype": "Data", "label": "Customer Name", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_19", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "customer_contact_person", "fieldtype": "Link", "label": "Customer Contact", - "options": "Contact" + "options": "Contact", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "customer_contact_display", "fieldtype": "Small Text", "hidden": 1, "label": "Customer Contact", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "customer_contact_mobile", "fieldtype": "Small Text", "hidden": 1, "label": "Customer Mobile No", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "customer_contact_email", @@ -308,27 +350,35 @@ "hidden": 1, "label": "Customer Contact Email", "options": "Email", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "section_addresses", "fieldtype": "Section Break", - "label": "Address and Contact" + "label": "Address and Contact", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "supplier_address", "fieldtype": "Link", "label": "Supplier Address", "options": "Address", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "contact_person", "fieldtype": "Link", "label": "Supplier Contact", "options": "Contact", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "address_display", @@ -355,32 +405,42 @@ "label": "Contact Email", "options": "Email", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "col_break_address", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "shipping_address", "fieldtype": "Link", "label": "Company Shipping Address", "options": "Address", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "shipping_address_display", "fieldtype": "Small Text", "label": "Shipping Address Details", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", - "options": "fa fa-tag" + "options": "fa fa-tag", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "currency", @@ -390,7 +450,9 @@ "oldfieldtype": "Select", "options": "Currency", "print_hide": 1, - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "conversion_rate", @@ -400,18 +462,24 @@ "oldfieldtype": "Currency", "precision": "9", "print_hide": 1, - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "cb_price_list", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "buying_price_list", "fieldtype": "Link", "label": "Price List", "options": "Price List", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "price_list_currency", @@ -419,14 +487,18 @@ "label": "Price List Currency", "options": "Currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "plc_conversion_rate", "fieldtype": "Float", "label": "Price List Exchange Rate", "precision": "9", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", @@ -435,7 +507,9 @@ "label": "Ignore Pricing Rule", "no_copy": 1, "permlevel": 1, - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "sec_warehouse", @@ -448,11 +522,15 @@ "fieldtype": "Link", "label": "Set Target Warehouse", "options": "Warehouse", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "col_break_warehouse", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "default": "No", @@ -461,26 +539,34 @@ "in_standard_filter": 1, "label": "Supply Raw Materials", "options": "No\nYes", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.is_subcontracted==\"Yes\"", "fieldname": "supplier_warehouse", "fieldtype": "Link", "label": "Supplier Warehouse", - "options": "Warehouse" + "options": "Warehouse", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "items_section", "fieldtype": "Section Break", "hide_border": 1, "oldfieldtype": "Section Break", - "options": "fa fa-shopping-cart" + "options": "fa fa-shopping-cart", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "scan_barcode", "fieldtype": "Data", - "label": "Scan Barcode" + "label": "Scan Barcode", + "show_days": 1, + "show_seconds": 1 }, { "allow_bulk_edit": 1, @@ -490,46 +576,61 @@ "oldfieldname": "po_details", "oldfieldtype": "Table", "options": "Purchase Order Item", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "section_break_48", "fieldtype": "Section Break", - "label": "Pricing Rules" + "label": "Pricing Rules", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "pricing_rules", "fieldtype": "Table", "label": "Purchase Order Pricing Rule", "options": "Pricing Rule Detail", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible_depends_on": "supplied_items", "fieldname": "raw_material_details", "fieldtype": "Section Break", - "label": "Raw Materials Supplied" + "label": "Raw Materials Supplied", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "supplied_items", "fieldtype": "Table", "label": "Supplied Items", + "no_copy": 1, "oldfieldname": "po_raw_material_details", "oldfieldtype": "Table", "options": "Purchase Order Item Supplied", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "sb_last_purchase", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "total_qty", "fieldtype": "Float", "label": "Total Quantity", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "base_total", @@ -537,7 +638,9 @@ "label": "Total (Company Currency)", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "base_net_total", @@ -548,18 +651,24 @@ "oldfieldtype": "Currency", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_26", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "total", "fieldtype": "Currency", "label": "Total", "options": "currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "net_total", @@ -569,20 +678,26 @@ "oldfieldtype": "Currency", "options": "currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "total_net_weight", "fieldtype": "Float", "label": "Total Net Weight", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "taxes_section", "fieldtype": "Section Break", "oldfieldtype": "Section Break", - "options": "fa fa-money" + "options": "fa fa-money", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "taxes_and_charges", @@ -591,18 +706,24 @@ "oldfieldname": "purchase_other_charges", "oldfieldtype": "Link", "options": "Purchase Taxes and Charges Template", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_50", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "shipping_rule", "fieldtype": "Link", "label": "Shipping Rule", "options": "Shipping Rule", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "section_break_52", @@ -615,13 +736,17 @@ "label": "Purchase Taxes and Charges", "oldfieldname": "purchase_tax_details", "oldfieldtype": "Table", - "options": "Purchase Taxes and Charges" + "options": "Purchase Taxes and Charges", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "sec_tax_breakup", "fieldtype": "Section Break", - "label": "Tax Breakup" + "label": "Tax Breakup", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "other_charges_calculation", @@ -630,14 +755,18 @@ "no_copy": 1, "oldfieldtype": "HTML", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "totals", "fieldtype": "Section Break", "label": "Taxes and Charges", "oldfieldtype": "Section Break", - "options": "fa fa-money" + "options": "fa fa-money", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "base_taxes_and_charges_added", @@ -648,7 +777,9 @@ "oldfieldtype": "Currency", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "base_taxes_and_charges_deducted", @@ -659,7 +790,9 @@ "oldfieldtype": "Currency", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "base_total_taxes_and_charges", @@ -671,11 +804,15 @@ "oldfieldtype": "Currency", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_39", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "taxes_and_charges_added", @@ -686,7 +823,9 @@ "oldfieldtype": "Currency", "options": "currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "taxes_and_charges_deducted", @@ -697,7 +836,9 @@ "oldfieldtype": "Currency", "options": "currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "total_taxes_and_charges", @@ -706,14 +847,18 @@ "label": "Total Taxes and Charges", "options": "currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "collapsible_depends_on": "apply_discount_on", "fieldname": "discount_section", "fieldtype": "Section Break", - "label": "Additional Discount" + "label": "Additional Discount", + "show_days": 1, + "show_seconds": 1 }, { "default": "Grand Total", @@ -721,7 +866,9 @@ "fieldtype": "Select", "label": "Apply Additional Discount On", "options": "\nGrand Total\nNet Total", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "base_discount_amount", @@ -729,24 +876,32 @@ "label": "Additional Discount Amount (Company Currency)", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_45", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "additional_discount_percentage", "fieldtype": "Float", "label": "Additional Discount Percentage", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "discount_amount", "fieldtype": "Currency", "label": "Additional Discount Amount", "options": "currency", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "totals_section", @@ -762,16 +917,21 @@ "oldfieldtype": "Currency", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment (Company Currency)", "no_copy": 1, "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "description": "In Words will be visible once you save the Purchase Order.", @@ -782,7 +942,9 @@ "oldfieldname": "in_words", "oldfieldtype": "Data", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "base_rounded_total", @@ -792,12 +954,16 @@ "oldfieldtype": "Currency", "options": "Company:company:default_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break4", "fieldtype": "Column Break", - "oldfieldtype": "Column Break" + "oldfieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "grand_total", @@ -807,29 +973,38 @@ "oldfieldname": "grand_total_import", "oldfieldtype": "Currency", "options": "currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment", "no_copy": 1, "options": "currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "rounded_total", "fieldtype": "Currency", "label": "Rounded Total", "options": "currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "disable_rounded_total", "fieldtype": "Check", - "label": "Disable Rounded Total" + "label": "Disable Rounded Total", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "in_words", @@ -839,7 +1014,9 @@ "oldfieldname": "in_words_import", "oldfieldtype": "Data", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "advance_paid", @@ -848,19 +1025,25 @@ "no_copy": 1, "options": "party_account_currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "payment_schedule_section", "fieldtype": "Section Break", - "label": "Payment Terms" + "label": "Payment Terms", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "payment_terms_template", "fieldtype": "Link", "label": "Payment Terms Template", - "options": "Payment Terms Template" + "options": "Payment Terms Template", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "payment_schedule", @@ -868,7 +1051,9 @@ "label": "Payment Schedule", "no_copy": 1, "options": "Payment Schedule", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, @@ -877,7 +1062,9 @@ "fieldtype": "Section Break", "label": "Terms and Conditions", "oldfieldtype": "Section Break", - "options": "fa fa-legal" + "options": "fa fa-legal", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "tc_name", @@ -886,21 +1073,27 @@ "oldfieldname": "tc_name", "oldfieldtype": "Link", "options": "Terms and Conditions", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "terms", "fieldtype": "Text Editor", "label": "Terms and Conditions", "oldfieldname": "terms", - "oldfieldtype": "Text Editor" + "oldfieldtype": "Text Editor", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "more_info", "fieldtype": "Section Break", "label": "More Information", - "oldfieldtype": "Section Break" + "oldfieldtype": "Section Break", + "show_days": 1, + "show_seconds": 1 }, { "default": "Draft", @@ -915,7 +1108,9 @@ "print_hide": 1, "read_only": 1, "reqd": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "ref_sq", @@ -926,7 +1121,9 @@ "oldfieldtype": "Data", "options": "Supplier Quotation", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "party_account_currency", @@ -936,18 +1133,24 @@ "no_copy": 1, "options": "Currency", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "inter_company_order_reference", "fieldtype": "Link", "label": "Inter Company Order Reference", "options": "Sales Order", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_74", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:!doc.__islocal", @@ -957,7 +1160,9 @@ "label": "% Received", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:!doc.__islocal", @@ -967,7 +1172,9 @@ "label": "% Billed", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, @@ -977,6 +1184,8 @@ "oldfieldtype": "Column Break", "print_hide": 1, "print_width": "50%", + "show_days": 1, + "show_seconds": 1, "width": "50%" }, { @@ -987,7 +1196,9 @@ "oldfieldname": "letter_head", "oldfieldtype": "Select", "options": "Letter Head", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, @@ -999,11 +1210,15 @@ "oldfieldtype": "Link", "options": "Print Heading", "print_hide": 1, - "report_hide": 1 + "report_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_86", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, @@ -1011,19 +1226,25 @@ "fieldname": "group_same_items", "fieldtype": "Check", "label": "Group same items", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "language", "fieldtype": "Data", "label": "Print Language", - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "subscription_section", "fieldtype": "Section Break", - "label": "Subscription Section" + "label": "Subscription Section", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, @@ -1031,7 +1252,9 @@ "fieldtype": "Date", "label": "From Date", "no_copy": 1, - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, @@ -1039,11 +1262,15 @@ "fieldtype": "Date", "label": "To Date", "no_copy": 1, - "print_hide": 1 + "print_hide": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_97", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "auto_repeat", @@ -1052,27 +1279,35 @@ "no_copy": 1, "options": "Auto Repeat", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "depends_on": "eval: doc.auto_repeat", "fieldname": "update_auto_repeat_reference", "fieldtype": "Button", - "label": "Update Auto Repeat Reference" + "label": "Update Auto Repeat Reference", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "tax_category", "fieldtype": "Link", "label": "Tax Category", - "options": "Tax Category" + "options": "Tax Category", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "supplied_items", "fieldname": "set_reserve_warehouse", "fieldtype": "Link", "label": "Set Reserve Warehouse", - "options": "Warehouse" + "options": "Warehouse", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, @@ -1082,7 +1317,9 @@ }, { "fieldname": "column_break_75", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "billing_address", @@ -1118,13 +1355,30 @@ "label": "Represents Company", "options": "Company", "read_only": 1 + }, + { + "default": "0", + "fieldname": "apply_tds", + "fieldtype": "Check", + "label": "Apply Tax Withholding Amount", + "show_days": 1, + "show_seconds": 1 + }, + { + "depends_on": "eval: doc.apply_tds", + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "label": "Tax Withholding Category", + "options": "Tax Withholding Category", + "show_days": 1, + "show_seconds": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2021-01-20 22:07:23.487138", + "modified": "2021-05-30 15:17:53.663648", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c023accbfc0..eaa502ff7f0 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -14,11 +14,11 @@ from frappe.desk.notifications import clear_doctype_notifications from erpnext.buying.utils import validate_for_items, check_on_hold_or_closed_status from erpnext.stock.utils import get_bin from erpnext.accounts.party import get_party_account_currency -from six import string_types from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.accounts.doctype.sales_invoice.sales_invoice import validate_inter_company_party, update_linked_doc,\ - unlink_inter_company_doc +from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import get_party_tax_withholding_details +from erpnext.accounts.doctype.sales_invoice.sales_invoice import (validate_inter_company_party, + update_linked_doc, unlink_inter_company_doc) form_grid_templates = { "items": "templates/form_grid/item_grid.html" @@ -39,11 +39,18 @@ class PurchaseOrder(BuyingController): 'percent_join_field': 'material_request' }] + def onload(self): + supplier_tds = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") + self.set_onload("supplier_tds", supplier_tds) + def validate(self): super(PurchaseOrder, self).validate() self.set_status() + # apply tax withholding only if checked and applicable + self.set_tax_withholding() + self.validate_supplier() self.validate_schedule_date() validate_for_items(self) @@ -87,6 +94,33 @@ class PurchaseOrder(BuyingController): if cint(frappe.db.get_single_value('Buying Settings', 'maintain_same_rate')): self.validate_rate_with_reference_doc([["Supplier Quotation", "supplier_quotation", "supplier_quotation_item"]]) + def set_tax_withholding(self): + if not self.apply_tds: + return + + tax_withholding_details = get_party_tax_withholding_details(self, self.tax_withholding_category) + + if not tax_withholding_details: + return + + accounts = [] + for d in self.taxes: + if d.account_head == tax_withholding_details.get("account_head"): + d.update(tax_withholding_details) + accounts.append(d.account_head) + + if not accounts or tax_withholding_details.get("account_head") not in accounts: + self.append("taxes", tax_withholding_details) + + to_remove = [d for d in self.taxes + if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head")] + + for d in to_remove: + self.remove(d) + + # calculate totals again after applying TDS + self.calculate_taxes_and_totals() + def validate_supplier(self): prevent_po = frappe.db.get_value("Supplier", self.supplier, 'prevent_pos') if prevent_po: @@ -468,9 +502,11 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions @frappe.whitelist() def make_rm_stock_entry(purchase_order, rm_items): - if isinstance(rm_items, string_types): + rm_items_list = rm_items + + if isinstance(rm_items, str): rm_items_list = json.loads(rm_items) - else: + elif not rm_items: frappe.throw(_("No Items available for transfer")) if rm_items_list: @@ -508,6 +544,8 @@ def make_rm_stock_entry(purchase_order, rm_items): 'qty': rm_item_data["qty"], 'from_warehouse': rm_item_data["warehouse"], 'stock_uom': rm_item_data["stock_uom"], + 'serial_no': rm_item_data.get('serial_no'), + 'batch_no': rm_item_data.get('batch_no'), 'main_item_code': rm_item_data["item_code"], 'allow_alternative_item': item_wh.get(rm_item_code, {}).get('allow_alternative_item') } @@ -547,3 +585,58 @@ def update_status(status, name): def make_inter_company_sales_order(source_name, target_doc=None): from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction return make_inter_company_transaction("Purchase Order", source_name, target_doc) + +@frappe.whitelist() +def get_materials_from_supplier(purchase_order, po_details): + if isinstance(po_details, str): + po_details = json.loads(po_details) + + doc = frappe.get_cached_doc('Purchase Order', purchase_order) + doc.initialized_fields() + doc.purchase_orders = [doc.name] + doc.get_available_materials() + + if not doc.available_materials: + frappe.throw(_('Materials are already received against the purchase order {0}') + .format(purchase_order)) + + return make_return_stock_entry_for_subcontract(doc.available_materials, doc, po_details) + +def make_return_stock_entry_for_subcontract(available_materials, po_doc, po_details): + ste_doc = frappe.new_doc('Stock Entry') + ste_doc.purpose = 'Material Transfer' + ste_doc.purchase_order = po_doc.name + ste_doc.company = po_doc.company + ste_doc.is_return = 1 + + for key, value in available_materials.items(): + if not value.qty: + continue + + if value.batch_no: + for batch_no, qty in value.batch_no.items(): + if qty > 0: + add_items_in_ste(ste_doc, value, value.qty, po_details, batch_no) + else: + add_items_in_ste(ste_doc, value, value.qty, po_details) + + ste_doc.set_stock_entry_type() + ste_doc.calculate_rate_and_amount() + + return ste_doc + +def add_items_in_ste(ste_doc, row, qty, po_details, batch_no=None): + item = ste_doc.append('items', row.item_details) + + po_detail = list(set(row.po_details).intersection(po_details)) + item.update({ + 'qty': qty, + 'batch_no': batch_no, + 'basic_rate': row.item_details['rate'], + 'po_detail': po_detail[0] if po_detail else '', + 's_warehouse': row.item_details['t_warehouse'], + 't_warehouse': row.item_details['s_warehouse'], + 'item_code': row.item_details['rm_item_code'], + 'subcontracted_item': row.item_details['main_item_code'], + 'serial_no': '\n'.join(row.serial_no) if row.serial_no else '' + }) \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 565fc920b8e..8563b97ab74 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -20,7 +20,6 @@ from erpnext.controllers.status_updater import OverAllowanceError from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order from erpnext.stock.doctype.batch.test_batch import make_new_batch -from erpnext.controllers.buying_controller import get_backflushed_subcontracted_raw_materials class TestPurchaseOrder(unittest.TestCase): def test_make_purchase_receipt(self): @@ -771,7 +770,7 @@ class TestPurchaseOrder(unittest.TestCase): self.assertEqual(bin11.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract) def test_exploded_items_in_subcontracted(self): - item_code = "_Test Subcontracted FG Item 1" + item_code = "_Test Subcontracted FG Item 11" make_subcontracted_item(item_code=item_code) po = create_purchase_order(item_code=item_code, qty=1, @@ -848,79 +847,6 @@ class TestPurchaseOrder(unittest.TestCase): for item in rm_items: transferred_rm_map[item.get('rm_item_code')] = item - for item in pr.get('supplied_items'): - self.assertEqual(item.get('required_qty'), (transferred_rm_map[item.get('rm_item_code')].get('qty') / order_qty) * received_qty) - - update_backflush_based_on("BOM") - - def test_backflushed_based_on_for_multiple_batches(self): - item_code = "_Test Subcontracted FG Item 2" - make_item('Sub Contracted Raw Material 2', { - 'is_stock_item': 1, - 'is_sub_contracted_item': 1 - }) - - make_subcontracted_item(item_code=item_code, has_batch_no=1, create_new_batch=1, - raw_materials=["Sub Contracted Raw Material 2"]) - - update_backflush_based_on("Material Transferred for Subcontract") - - order_qty = 500 - po = create_purchase_order(item_code=item_code, qty=order_qty, - is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC") - - make_stock_entry(target="_Test Warehouse - _TC", - item_code = "Sub Contracted Raw Material 2", qty=552, basic_rate=100) - - rm_items = [ - {"item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 2","item_name":"_Test Item", - "qty":552,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos"}] - - rm_item_string = json.dumps(rm_items) - se = frappe.get_doc(make_subcontract_transfer_entry(po.name, rm_item_string)) - se.submit() - - for batch in ["ABCD1", "ABCD2", "ABCD3", "ABCD4"]: - make_new_batch(batch_id=batch, item_code=item_code) - - pr = make_purchase_receipt(po.name) - - # partial receipt - pr.get('items')[0].qty = 30 - pr.get('items')[0].batch_no = "ABCD1" - - purchase_order = po.name - purchase_order_item = po.items[0].name - - for batch_no, qty in {"ABCD2": 60, "ABCD3": 70, "ABCD4":40}.items(): - pr.append("items", { - "item_code": pr.get('items')[0].item_code, - "item_name": pr.get('items')[0].item_name, - "uom": pr.get('items')[0].uom, - "stock_uom": pr.get('items')[0].stock_uom, - "warehouse": pr.get('items')[0].warehouse, - "conversion_factor": pr.get('items')[0].conversion_factor, - "cost_center": pr.get('items')[0].cost_center, - "rate": pr.get('items')[0].rate, - "qty": qty, - "batch_no": batch_no, - "purchase_order": purchase_order, - "purchase_order_item": purchase_order_item - }) - - pr.submit() - - pr1 = make_purchase_receipt(po.name) - pr1.get('items')[0].qty = 300 - pr1.get('items')[0].batch_no = "ABCD1" - pr1.save() - - pr_key = ("Sub Contracted Raw Material 2", po.name) - consumed_qty = get_backflushed_subcontracted_raw_materials([po.name]).get(pr_key) - - self.assertTrue(pr1.supplied_items[0].consumed_qty > 0) - self.assertTrue(pr1.supplied_items[0].consumed_qty, flt(552.0) - flt(consumed_qty)) - update_backflush_based_on("BOM") def test_supplied_qty_against_subcontracted_po(self): @@ -1111,28 +1037,35 @@ def create_purchase_order(**args): po.schedule_date = add_days(nowdate(), 1) po.company = args.company or "_Test Company" - po.supplier = args.customer or "_Test Supplier" + po.supplier = args.supplier or "_Test Supplier" po.is_subcontracted = args.is_subcontracted or "No" 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 - po.append("items", { - "item_code": args.item or args.item_code or "_Test Item", - "warehouse": args.warehouse or "_Test Warehouse - _TC", - "qty": args.qty or 10, - "rate": args.rate or 500, - "schedule_date": add_days(nowdate(), 1), - "include_exploded_items": args.get('include_exploded_items', 1), - "against_blanket_order": args.against_blanket_order - }) + if args.rm_items: + for row in args.rm_items: + po.append("items", row) + else: + po.append("items", { + "item_code": args.item or args.item_code or "_Test Item", + "warehouse": args.warehouse or "_Test Warehouse - _TC", + "qty": args.qty or 10, + "rate": args.rate or 500, + "schedule_date": add_days(nowdate(), 1), + "include_exploded_items": args.get('include_exploded_items', 1), + "against_blanket_order": args.against_blanket_order + }) + + po.set_missing_values() if not args.do_not_save: po.insert() if not args.do_not_submit: if po.is_subcontracted == "Yes": supp_items = po.get("supplied_items") for d in supp_items: - d.reserve_warehouse = args.warehouse or "_Test Warehouse - _TC" + if not d.reserve_warehouse: + d.reserve_warehouse = args.warehouse or "_Test Warehouse - _TC" po.submit() return po 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 1dbd7c60c3e..132dd1769c5 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -97,6 +97,9 @@ "is_fixed_asset", "item_tax_rate", "section_break_72", + "production_plan", + "production_plan_item", + "production_plan_sub_assembly_item", "page_break" ], "fields": [ @@ -803,13 +806,37 @@ "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "production_plan", + "fieldtype": "Link", + "label": "Production Plan", + "options": "Production Plan", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "production_plan_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Item", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "production_plan_sub_assembly_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Sub Assembly Item", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-03-22 11:46:12.357435", + "modified": "2021-06-28 19:22:22.715365", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/purchase_order_item_supplied/purchase_order_item_supplied.json b/erpnext/buying/doctype/purchase_order_item_supplied/purchase_order_item_supplied.json index d7ea9c1ccc8..60247bd90bb 100644 --- a/erpnext/buying/doctype/purchase_order_item_supplied/purchase_order_item_supplied.json +++ b/erpnext/buying/doctype/purchase_order_item_supplied/purchase_order_item_supplied.json @@ -6,21 +6,25 @@ "engine": "InnoDB", "field_order": [ "main_item_code", - "bom_detail_no", + "rm_item_code", + "column_break_3", "stock_uom", + "reserve_warehouse", "conversion_factor", "column_break_6", - "rm_item_code", + "bom_detail_no", "reference_name", - "reserve_warehouse", "section_break2", "rate", "col_break2", "amount", "section_break1", "required_qty", + "supplied_qty", "col_break1", - "supplied_qty" + "consumed_qty", + "returned_qty", + "total_supplied_qty" ], "fields": [ { @@ -125,6 +129,8 @@ "fieldtype": "Float", "in_list_view": 1, "label": "Supplied Qty", + "no_copy": 1, + "print_hide": 1, "read_only": 1 }, { @@ -142,13 +148,42 @@ { "fieldname": "col_break2", "fieldtype": "Column Break" + }, + { + "fieldname": "consumed_qty", + "fieldtype": "Float", + "label": "Consumed Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "returned_qty", + "fieldtype": "Float", + "label": "Returned Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "total_supplied_qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Total Supplied Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" } ], "hide_toolbar": 1, "idx": 1, "istable": 1, "links": [], - "modified": "2020-09-18 17:26:09.703215", + "modified": "2021-06-09 15:17:58.128242", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item Supplied", diff --git a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json index dc00bca5cc5..f9cd72015a6 100644 --- a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json +++ b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json @@ -6,10 +6,11 @@ "engine": "InnoDB", "field_order": [ "main_item_code", - "description", + "rm_item_code", + "item_name", "bom_detail_no", "col_break1", - "rm_item_code", + "description", "stock_uom", "conversion_factor", "reference_name", @@ -25,7 +26,8 @@ "secbreak_3", "batch_no", "col_break4", - "serial_no" + "serial_no", + "purchase_order" ], "fields": [ { @@ -52,7 +54,6 @@ "fieldname": "description", "fieldtype": "Text Editor", "in_global_search": 1, - "in_list_view": 1, "label": "Description", "oldfieldname": "description", "oldfieldtype": "Data", @@ -81,18 +82,20 @@ "fieldname": "required_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Required Qty", + "label": "Available Qty For Consumption", "oldfieldname": "required_qty", "oldfieldtype": "Currency", + "print_hide": 1, "read_only": 1 }, { + "columns": 2, "fieldname": "consumed_qty", "fieldtype": "Float", - "label": "Consumed Qty", + "in_list_view": 1, + "label": "Qty to Be Consumed", "oldfieldname": "consumed_qty", "oldfieldtype": "Currency", - "read_only": 1, "reqd": 1 }, { @@ -183,12 +186,28 @@ { "fieldname": "col_break4", "fieldtype": "Column Break" + }, + { + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "purchase_order", + "fieldtype": "Link", + "hidden": 1, + "label": "Purchase Order", + "no_copy": 1, + "options": "Purchase Order", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-09-18 17:26:09.703215", + "modified": "2021-06-19 19:33:04.431213", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Receipt Item Supplied", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 0127eb81630..a4ce84e1cf9 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -317,19 +317,21 @@ def add_items(sq_doc, supplier, items): create_rfq_items(sq_doc, supplier, data) def create_rfq_items(sq_doc, supplier, data): - sq_doc.append('items', { - "item_code": data.item_code, - "item_name": data.item_name, - "description": data.description, - "qty": data.qty, - "rate": data.rate, - "conversion_factor": data.conversion_factor if data.conversion_factor else None, - "supplier_part_no": frappe.db.get_value("Item Supplier", {'parent': data.item_code, 'supplier': supplier}, "supplier_part_no"), - "warehouse": data.warehouse or '', + args = {} + + for field in ['item_code', 'item_name', 'description', 'qty', 'rate', 'conversion_factor', + 'warehouse', 'material_request', 'material_request_item', 'stock_qty']: + args[field] = data.get(field) + + args.update({ "request_for_quotation_item": data.name, - "request_for_quotation": data.parent + "request_for_quotation": data.parent, + "supplier_part_no": frappe.db.get_value("Item Supplier", + {'parent': data.item_code, 'supplier': supplier}, "supplier_part_no") }) + sq_doc.append('items', args) + @frappe.whitelist() def get_pdf(doctype, name, supplier): doc = get_rfq_doc(doctype, name, supplier) diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index 4ddc458175b..1766c2c80cc 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -60,10 +60,23 @@ frappe.ui.form.on("Supplier", { erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name); }, __('Create')); + frm.add_custom_button(__('Get Supplier Group Details'), function () { + frm.trigger("get_supplier_group_details"); + }, __('Actions')); + // indicators erpnext.utils.set_party_dashboard_indicators(frm); } }, + get_supplier_group_details: function(frm) { + frappe.call({ + method: "get_supplier_group_details", + doc: frm.doc, + callback: function() { + frm.refresh(); + } + }); + }, is_internal_supplier: function(frm) { if (frm.doc.is_internal_supplier == 1) { diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index edeb135d951..fd16b23c220 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -51,6 +51,23 @@ class Supplier(TransactionBase): validate_party_accounts(self) self.validate_internal_supplier() + @frappe.whitelist() + def get_supplier_group_details(self): + doc = frappe.get_doc('Supplier Group', self.supplier_group) + self.payment_terms = "" + self.accounts = [] + + if doc.accounts: + for account in doc.accounts: + child = self.append('accounts') + child.company = account.company + child.account = account.account + + if doc.payment_terms: + self.payment_terms = doc.payment_terms + + self.save() + def validate_internal_supplier(self): internal_supplier = frappe.db.get_value("Supplier", {"is_internal_supplier": 1, "represents_company": self.represents_company, "name": ("!=", self.name)}, "name") @@ -86,4 +103,4 @@ class Supplier(TransactionBase): create_contact(supplier, 'Supplier', doc.name, args.get('supplier_email_' + str(i))) except frappe.NameError: - pass \ No newline at end of file + pass diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index f9c8d35518d..89804662700 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -13,6 +13,30 @@ test_records = frappe.get_test_records('Supplier') class TestSupplier(unittest.TestCase): + def test_get_supplier_group_details(self): + doc = frappe.new_doc("Supplier Group") + doc.supplier_group_name = "_Testing Supplier Group" + doc.payment_terms = "_Test Payment Term Template 3" + doc.accounts = [] + test_account_details = { + "company": "_Test Company", + "account": "Creditors - _TC", + } + doc.append("accounts", test_account_details) + doc.save() + s_doc = frappe.new_doc("Supplier") + s_doc.supplier_name = "Testing Supplier" + s_doc.supplier_group = "_Testing Supplier Group" + s_doc.payment_terms = "" + s_doc.accounts = [] + s_doc.insert() + s_doc.get_supplier_group_details() + self.assertEqual(s_doc.payment_terms, "_Test Payment Term Template 3") + self.assertEqual(s_doc.accounts[0].company, "_Test Company") + self.assertEqual(s_doc.accounts[0].account, "Creditors - _TC") + s_doc.delete() + doc.delete() + def test_supplier_default_payment_terms(self): # Payment Term based on Days after invoice date frappe.db.set_value( @@ -136,4 +160,4 @@ def create_supplier(**args): return doc except frappe.DuplicateEntryError: - return frappe.get_doc("Supplier", args.supplier_name) \ No newline at end of file + return frappe.get_doc("Supplier", args.supplier_name) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 40fbe2c26e5..0a51a8e9a1c 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -576,6 +576,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment (Company Currency", @@ -620,6 +621,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment", @@ -802,7 +804,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-12-03 15:18:29.073368", + "modified": "2021-04-19 00:58:20.995491", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", diff --git a/erpnext/buying/report/subcontract_order_summary/__init__.py b/erpnext/buying/report/subcontract_order_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js new file mode 100644 index 00000000000..5ba52f1b21e --- /dev/null +++ b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js @@ -0,0 +1,45 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Subcontract Order Summary"] = { + "filters": [ + { + label: __("Company"), + fieldname: "company", + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + label: __("From Date"), + fieldname:"from_date", + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), + reqd: 1 + }, + { + label: __("To Date"), + fieldname:"to_date", + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 1 + }, + { + label: __("Purchase Order"), + fieldname: "name", + fieldtype: "Link", + options: "Purchase Order", + get_query: function() { + return { + filters: { + docstatus: 1, + is_subcontracted: 'Yes', + company: frappe.query_report.get_filter_value('company') + } + } + } + } + ] +}; diff --git a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.json b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.json new file mode 100644 index 00000000000..526a8d8ad01 --- /dev/null +++ b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-05-31 14:43:32.417694", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-05-31 14:43:32.417694", + "modified_by": "Administrator", + "module": "Buying", + "name": "Subcontract Order Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Purchase Order", + "report_name": "Subcontract Order Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Purchase Manager" + }, + { + "role": "Purchase User" + } + ] +} \ No newline at end of file diff --git a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py new file mode 100644 index 00000000000..0c0d4f0531d --- /dev/null +++ b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py @@ -0,0 +1,152 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ + +def execute(filters=None): + columns, data = [], [] + columns = get_columns() + data = get_data(filters) + + return columns, data + +def get_data(report_filters): + data = [] + orders = get_subcontracted_orders(report_filters) + + if orders: + supplied_items = get_supplied_items(orders, report_filters) + po_details = prepare_subcontracted_data(orders, supplied_items) + get_subcontracted_data(po_details, data) + + return data + +def get_subcontracted_orders(report_filters): + fields = ['`tabPurchase Order Item`.`parent` as po_id', '`tabPurchase Order Item`.`item_code`', + '`tabPurchase Order Item`.`item_name`', '`tabPurchase Order Item`.`qty`', '`tabPurchase Order Item`.`name`', + '`tabPurchase Order Item`.`received_qty`', '`tabPurchase Order`.`status`'] + + filters = get_filters(report_filters) + + return frappe.get_all('Purchase Order', fields = fields, filters=filters) or [] + +def get_filters(report_filters): + filters = [['Purchase Order', 'docstatus', '=', 1], ['Purchase Order', 'is_subcontracted', '=', 'Yes'], + ['Purchase Order', 'transaction_date', 'between', (report_filters.from_date, report_filters.to_date)]] + + for field in ['name', 'company']: + if report_filters.get(field): + filters.append(['Purchase Order', field, '=', report_filters.get(field)]) + + return filters + +def get_supplied_items(orders, report_filters): + if not orders: + return [] + + fields = ['parent', 'main_item_code', 'rm_item_code', 'required_qty', + 'supplied_qty', 'returned_qty', 'total_supplied_qty', 'consumed_qty', 'reference_name'] + + filters = {'parent': ('in', [d.po_id for d in orders]), 'docstatus': 1} + + supplied_items = {} + for row in frappe.get_all('Purchase Order Item Supplied', fields = fields, filters=filters): + new_key = (row.parent, row.reference_name, row.main_item_code) + + supplied_items.setdefault(new_key, []).append(row) + + return supplied_items + +def prepare_subcontracted_data(orders, supplied_items): + po_details = {} + for row in orders: + key = (row.po_id, row.name, row.item_code) + if key not in po_details: + po_details.setdefault(key, frappe._dict({'po_item': row, 'supplied_items': []})) + + details = po_details[key] + + if supplied_items.get(key): + for supplied_item in supplied_items[key]: + details['supplied_items'].append(supplied_item) + + return po_details + +def get_subcontracted_data(po_details, data): + for key, details in po_details.items(): + res = details.po_item + for index, row in enumerate(details.supplied_items): + if index != 0: + res = {} + + res.update(row) + data.append(res) + +def get_columns(): + return [ + { + "label": _("Purchase Order"), + "fieldname": "po_id", + "fieldtype": "Link", + "options": "Purchase Order", + "width": 100 + }, + { + "label": _("Status"), + "fieldname": "status", + "fieldtype": "Data", + "width": 80 + }, + { + "label": _("Subcontracted Item"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 160 + }, + { + "label": _("Order Qty"), + "fieldname": "qty", + "fieldtype": "Float", + "width": 90 + }, + { + "label": _("Received Qty"), + "fieldname": "received_qty", + "fieldtype": "Float", + "width": 110 + }, + { + "label": _("Supplied Item"), + "fieldname": "rm_item_code", + "fieldtype": "Link", + "options": "Item", + "width": 160 + }, + { + "label": _("Required Qty"), + "fieldname": "required_qty", + "fieldtype": "Float", + "width": 110 + }, + { + "label": _("Supplied Qty"), + "fieldname": "supplied_qty", + "fieldtype": "Float", + "width": 110 + }, + { + "label": _("Consumed Qty"), + "fieldname": "consumed_qty", + "fieldtype": "Float", + "width": 120 + }, + { + "label": _("Returned Qty"), + "fieldname": "returned_qty", + "fieldtype": "Float", + "width": 110 + } + ] \ No newline at end of file diff --git a/erpnext/change_log/v13/v13_5_0.md b/erpnext/change_log/v13/v13_5_0.md new file mode 100644 index 00000000000..64c323a23e5 --- /dev/null +++ b/erpnext/change_log/v13/v13_5_0.md @@ -0,0 +1,54 @@ +# Version 13.5.0 Release Notes + +### Features & Enhancements + +- Tax deduction against advance payments ([#25831](https://github.com/frappe/erpnext/pull/25831)) +- Cost-center wise period closing entry ([#25766](https://github.com/frappe/erpnext/pull/25766)) +- Create Quality Inspections from account and stock documents ([#25221](https://github.com/frappe/erpnext/pull/25221)) +- Item Taxes based on net rate ([#25961](https://github.com/frappe/erpnext/pull/25961)) +- Enable/disable gl entry posting for change given in pos ([#25822](https://github.com/frappe/erpnext/pull/25822)) +- Add Inactive status to Employee ([#26029](https://github.com/frappe/erpnext/pull/26029)) +- Added check box to combine items with same BOM ([#25478](https://github.com/frappe/erpnext/pull/25478)) +- Item Tax Templates for Germany ([#25858](https://github.com/frappe/erpnext/pull/25858)) +- Refactored leave balance report ([#25771](https://github.com/frappe/erpnext/pull/25771)) +- Refactored Vehicle Expenses Report ([#25727](https://github.com/frappe/erpnext/pull/25727)) +- Refactored maintenance schedule and visit document ([#25358](https://github.com/frappe/erpnext/pull/25358)) + +### Fixes + +- Cannot add same item with different rates ([#25849](https://github.com/frappe/erpnext/pull/25849)) +- Show only company addresses for ITC reversal entry ([#25866](https://github.com/frappe/erpnext/pull/25866)) +- Hiding Rounding Adjustment field ([#25380](https://github.com/frappe/erpnext/pull/25380)) +- Auto tax calculations in Payment Entry ([#26055](https://github.com/frappe/erpnext/pull/26055)) +- Not able to select the item code in work order ([#25915](https://github.com/frappe/erpnext/pull/25915)) +- Cannot reset plaid link for a bank account ([#25869](https://github.com/frappe/erpnext/pull/25869)) +- Student invalid password reset link ([#25826](https://github.com/frappe/erpnext/pull/25826)) +- Multiple pos issues ([#25928](https://github.com/frappe/erpnext/pull/25928)) +- Add Product Bundles to POS ([#25860](https://github.com/frappe/erpnext/pull/25860)) +- Enable Parallel tests ([#25862](https://github.com/frappe/erpnext/pull/25862)) +- Service item check on e-Invoicing ([#25986](https://github.com/frappe/erpnext/pull/25986)) +- Choose correct Salary Structure Assignment when getting data for formula eval ([#25981](https://github.com/frappe/erpnext/pull/25981)) +- Ignore internal transfer invoices from GST Reports ([#25969](https://github.com/frappe/erpnext/pull/25969)) +- Taxable value for invoices with additional discount ([#26056](https://github.com/frappe/erpnext/pull/26056)) +- Validate negative allocated amount in Payment Entry ([#25799](https://github.com/frappe/erpnext/pull/25799)) +- Allow all System Managers to delete company transactions ([#25834](https://github.com/frappe/erpnext/pull/25834)) +- Wrong round off gl entry posted in case of purchase invoice ([#25775](https://github.com/frappe/erpnext/pull/25775)) +- Use dictionary filter instead of list ([#25874](https://github.com/frappe/erpnext/pull/25874)) +- Ageing error in PSOA ([#25855](https://github.com/frappe/erpnext/pull/25855)) +- On click of duplicate button system has not copied the difference account ([#25988](https://github.com/frappe/erpnext/pull/25988)) +- Assign Product Bundle's conversion_factor to Pack… ([#25840](https://github.com/frappe/erpnext/pull/25840)) +- Rename Loan Management workspace to Loans ([#25856](https://github.com/frappe/erpnext/pull/25856)) +- Fix stock quantity calculation when negative_stock_allowe… ([#25859](https://github.com/frappe/erpnext/pull/25859)) +- Update cost center from pos profile ([#25971](https://github.com/frappe/erpnext/pull/25971)) +- Ensure website theme is applied correctly ([#25863](https://github.com/frappe/erpnext/pull/25863)) +- Only display GST card in Accounting Workspace if it's in India ([#26000](https://github.com/frappe/erpnext/pull/26000)) +- Incorrect gstin fetched incase of branch company address ([#25841](https://github.com/frappe/erpnext/pull/25841)) +- Sort account balances by account name ([#26009](https://github.com/frappe/erpnext/pull/26009)) +- Custom conversion factor field not mapped from job card to stock entry ([#25956](https://github.com/frappe/erpnext/pull/25956)) +- Chart of accounts importer always error ([#25882](https://github.com/frappe/erpnext/pull/25882)) +- Create POS Invoice for Product Bundles ([#25847](https://github.com/frappe/erpnext/pull/25847)) +- Wrap dates in getdate for leave application ([#25899](https://github.com/frappe/erpnext/pull/25899)) +- Closing entry shows incorrect expected amount ([#25868](https://github.com/frappe/erpnext/pull/25868)) +- Add Hold status column in the Issue Summary Report ([#25828](https://github.com/frappe/erpnext/pull/25828)) +- Rendering of broken image on pos ([#25872](https://github.com/frappe/erpnext/pull/25872)) +- Timeout error in the repost item valuation ([#25854](https://github.com/frappe/erpnext/pull/25854)) \ No newline at end of file diff --git a/erpnext/change_log/v13/v13_6_0.md b/erpnext/change_log/v13/v13_6_0.md new file mode 100644 index 00000000000..d881b279e3f --- /dev/null +++ b/erpnext/change_log/v13/v13_6_0.md @@ -0,0 +1,72 @@ +# Version 13.6.0 Release Notes + +### Features & Enhancements + +- Job Card Enhancements ([#24523](https://github.com/frappe/erpnext/pull/24523)) +- Implement multi-account selection in General Ledger([#26044](https://github.com/frappe/erpnext/pull/26044)) +- Fetching of qty as per received qty from PR to PI ([#26184](https://github.com/frappe/erpnext/pull/26184)) +- Subcontract code refactor and enhancement ([#25878](https://github.com/frappe/erpnext/pull/25878)) +- Employee Grievance ([#25705](https://github.com/frappe/erpnext/pull/25705)) +- Add Inactive status to Employee ([#26030](https://github.com/frappe/erpnext/pull/26030)) +- Incorrect valuation rate report for serialized items ([#25696](https://github.com/frappe/erpnext/pull/25696)) +- Update cost updates operation time and hour rates in BOM ([#25891](https://github.com/frappe/erpnext/pull/25891)) + +### Fixes + +- Precision rate for packed items in internal transfers ([#26046](https://github.com/frappe/erpnext/pull/26046)) +- User is not able to change item tax template ([#26176](https://github.com/frappe/erpnext/pull/26176)) +- Insufficient permission for Dunning error ([#26092](https://github.com/frappe/erpnext/pull/26092)) +- Validate Product Bundle for existing transactions before deletion ([#25978](https://github.com/frappe/erpnext/pull/25978)) +- Auto unlink warehouse from item on delete ([#26073](https://github.com/frappe/erpnext/pull/26073)) +- Employee Inactive status implications ([#26245](https://github.com/frappe/erpnext/pull/26245)) +- Fetch batch items in stock reconciliation ([#26230](https://github.com/frappe/erpnext/pull/26230)) +- Disabled cancellation for sales order if linked to drafted sales invoice ([#26125](https://github.com/frappe/erpnext/pull/26125)) +- Sort website products by weightage mentioned in Item master ([#26134](https://github.com/frappe/erpnext/pull/26134)) +- Added freeze when trying to stop work order (#26192) ([#26196](https://github.com/frappe/erpnext/pull/26196)) +- Accounting Dimensions for payroll entry accrual Journal Entry ([#26083](https://github.com/frappe/erpnext/pull/26083)) +- Staffing plan vacancies data type issue ([#25941](https://github.com/frappe/erpnext/pull/25941)) +- Unable to enter score in Assessment Result details grid ([#25945](https://github.com/frappe/erpnext/pull/25945)) +- Report Subcontracted Raw Materials to be Transferred ([#26011](https://github.com/frappe/erpnext/pull/26011)) +- Label for enabling ledger posting of change amount ([#26070](https://github.com/frappe/erpnext/pull/26070)) +- Training event ([#26071](https://github.com/frappe/erpnext/pull/26071)) +- Rate not able to change in purchase order ([#26122](https://github.com/frappe/erpnext/pull/26122)) +- Error while fetching item taxes ([#26220](https://github.com/frappe/erpnext/pull/26220)) +- Check for duplicate payment terms in Payment Term Template ([#26003](https://github.com/frappe/erpnext/pull/26003)) +- Removed values out of sync validation from stock transactions ([#26229](https://github.com/frappe/erpnext/pull/26229)) +- Fetching employee in payroll entry ([#26269](https://github.com/frappe/erpnext/pull/26269)) +- Filter Cost Center and Project drop-down lists by Company ([#26045](https://github.com/frappe/erpnext/pull/26045)) +- Website item group logic for product listing in Item Group pages ([#26170](https://github.com/frappe/erpnext/pull/26170)) +- Chart not visible for First Response Time reports ([#26032](https://github.com/frappe/erpnext/pull/26032)) +- Incorrect billed qty in Sales Order analytics ([#26095](https://github.com/frappe/erpnext/pull/26095)) +- Material request and supplier quotation not linked if supplier quotation created from supplier portal ([#26023](https://github.com/frappe/erpnext/pull/26023)) +- Update leave allocation after submit ([#26191](https://github.com/frappe/erpnext/pull/26191)) +- Taxes on Internal Transfer payment entry ([#26188](https://github.com/frappe/erpnext/pull/26188)) +- Precision rate for packed items (bp #26046) ([#26217](https://github.com/frappe/erpnext/pull/26217)) +- Fixed rounding off ordered percent to 100 in condition ([#26152](https://github.com/frappe/erpnext/pull/26152)) +- Sanctioned loan amount limit check ([#26108](https://github.com/frappe/erpnext/pull/26108)) +- Purchase receipt gl entries with same item code ([#26202](https://github.com/frappe/erpnext/pull/26202)) +- Taxable value for invoices with additional discount ([#25906](https://github.com/frappe/erpnext/pull/25906)) +- Correct South Africa VAT Rate (Updated) ([#25894](https://github.com/frappe/erpnext/pull/25894)) +- Remove response_by and resolution_by if sla is removed ([#25997](https://github.com/frappe/erpnext/pull/25997)) +- POS loyalty card alignment ([#26051](https://github.com/frappe/erpnext/pull/26051)) +- Flaky test for Report Subcontracted Raw materials to be transferred ([#26043](https://github.com/frappe/erpnext/pull/26043)) +- Export invoices not visible in GSTR-1 report ([#26143](https://github.com/frappe/erpnext/pull/26143)) +- Account filter not working with accounting dimension filter ([#26211](https://github.com/frappe/erpnext/pull/26211)) +- Allow to select group warehouse while downloading materials from production plan ([#26126](https://github.com/frappe/erpnext/pull/26126)) +- Added freeze when trying to stop work order ([#26192](https://github.com/frappe/erpnext/pull/26192)) +- Time out while submit / cancel the stock transactions with more than 50 Items ([#26081](https://github.com/frappe/erpnext/pull/26081)) +- Address Card issues in e-commerce ([#26187](https://github.com/frappe/erpnext/pull/26187)) +- Error while booking deferred revenue ([#26195](https://github.com/frappe/erpnext/pull/26195)) +- Eliminate repeat creation of HSN codes ([#25947](https://github.com/frappe/erpnext/pull/25947)) +- Opening invoices can alter profit and loss of a closed year ([#25951](https://github.com/frappe/erpnext/pull/25951)) +- Payroll entry employee detail issue ([#25968](https://github.com/frappe/erpnext/pull/25968)) +- Auto tax calculations in Payment Entry ([#26037](https://github.com/frappe/erpnext/pull/26037)) +- Use pos invoice item name as unique identifier ([#26198](https://github.com/frappe/erpnext/pull/26198)) +- Billing address not fetched in Purchase Invoice ([#26100](https://github.com/frappe/erpnext/pull/26100)) +- Timeout while cancelling stock reconciliation ([#26098](https://github.com/frappe/erpnext/pull/26098)) +- Status indicator for delivery notes ([#26062](https://github.com/frappe/erpnext/pull/26062)) +- Unable to enter score in Assessment Result details grid ([#26031](https://github.com/frappe/erpnext/pull/26031)) +- Too many writes while renaming company abbreviation ([#26203](https://github.com/frappe/erpnext/pull/26203)) +- Chart not visible for First Response Time reports ([#26185](https://github.com/frappe/erpnext/pull/26185)) +- Job applicant link issue ([#25934](https://github.com/frappe/erpnext/pull/25934)) +- Fetch preferred shipping address (bp #26132) ([#26201](https://github.com/frappe/erpnext/pull/26201)) diff --git a/erpnext/change_log/v13/v13_7_0.md b/erpnext/change_log/v13/v13_7_0.md new file mode 100644 index 00000000000..589f610b939 --- /dev/null +++ b/erpnext/change_log/v13/v13_7_0.md @@ -0,0 +1,69 @@ +# Version 13.7.0 Release Notes + +### Features & Enhancements +- Optionally allow rejected quality inspection on submission ([#26133](https://github.com/frappe/erpnext/pull/26133)) +- Bootstrapped GST Setup for India ([#25415](https://github.com/frappe/erpnext/pull/25415)) +- Fetching details from supplier/customer groups ([#26454](https://github.com/frappe/erpnext/pull/26454)) +- Provision to make subcontracted purchase order from the production plan ([#26240](https://github.com/frappe/erpnext/pull/26240)) +- Optimized code for reposting item valuation ([#26432](https://github.com/frappe/erpnext/pull/26432)) + +### Fixes +- Auto process deferred accounting for multi-company setup ([#26277](https://github.com/frappe/erpnext/pull/26277)) +- Error while fetching item taxes ([#26218](https://github.com/frappe/erpnext/pull/26218)) +- Validation check for batch for stock reconciliation type in stock entry(bp #26370 ) ([#26488](https://github.com/frappe/erpnext/pull/26488)) +- Error popup for COA errors ([#26358](https://github.com/frappe/erpnext/pull/26358)) +- Precision for expected values in payment entry test ([#26394](https://github.com/frappe/erpnext/pull/26394)) +- Bank statement import ([#26287](https://github.com/frappe/erpnext/pull/26287)) +- LMS progress issue ([#26253](https://github.com/frappe/erpnext/pull/26253)) +- Paging buttons not working on item group portal page ([#26497](https://github.com/frappe/erpnext/pull/26497)) +- Omit item discount amount for e-invoicing ([#26353](https://github.com/frappe/erpnext/pull/26353)) +- Validate LCV for Invoices without Update Stock ([#26333](https://github.com/frappe/erpnext/pull/26333)) +- Remove cancelled entries in consolidated financial statements ([#26331](https://github.com/frappe/erpnext/pull/26331)) +- Fetching employee in payroll entry ([#26271](https://github.com/frappe/erpnext/pull/26271)) +- To fetch the correct field in Tax Rule ([#25927](https://github.com/frappe/erpnext/pull/25927)) +- Order and time of operations in multilevel BOM work order ([#25886](https://github.com/frappe/erpnext/pull/25886)) +- Fixed Budget Variance Graph color from all black to default ([#26368](https://github.com/frappe/erpnext/pull/26368)) +- TDS computation summary shows cancelled invoices (#26456) ([#26486](https://github.com/frappe/erpnext/pull/26486)) +- Do not consider cancelled entries in party dashboard ([#26231](https://github.com/frappe/erpnext/pull/26231)) +- Add validation for 'for_qty' else throws errors ([#25829](https://github.com/frappe/erpnext/pull/25829)) +- Move the rename abbreviation job to long queue (#26434) ([#26462](https://github.com/frappe/erpnext/pull/26462)) +- Query for Training Event ([#26388](https://github.com/frappe/erpnext/pull/26388)) +- Item group portal issues (backport) ([#26493](https://github.com/frappe/erpnext/pull/26493)) +- When lead is created with mobile_no, mobile_no value gets lost ([#26298](https://github.com/frappe/erpnext/pull/26298)) +- WIP needs to be set before submit on skip_transfer (bp #26499) ([#26507](https://github.com/frappe/erpnext/pull/26507)) +- Incorrect valuation rate in stock reconciliation ([#26259](https://github.com/frappe/erpnext/pull/26259)) +- Precision rate for packed items in internal transfers ([#26046](https://github.com/frappe/erpnext/pull/26046)) +- Changed profitability analysis report width ([#26165](https://github.com/frappe/erpnext/pull/26165)) +- Unable to download GSTR-1 json ([#26468](https://github.com/frappe/erpnext/pull/26468)) +- Unallocated amount in Payment Entry after taxes ([#26472](https://github.com/frappe/erpnext/pull/26472)) +- Include Stock Reco logic in `update_qty_in_future_sle` ([#26158](https://github.com/frappe/erpnext/pull/26158)) +- Update cost not working in the draft BOM ([#26279](https://github.com/frappe/erpnext/pull/26279)) +- Cancellation of Loan Security Pledges ([#26252](https://github.com/frappe/erpnext/pull/26252)) +- fix(e-invoicing): allow export invoice even if no taxes applied (#26363) ([#26405](https://github.com/frappe/erpnext/pull/26405)) +- Delete accounts (an empty file) ([#25323](https://github.com/frappe/erpnext/pull/25323)) +- Errors on parallel requests creation of company for India ([#26470](https://github.com/frappe/erpnext/pull/26470)) +- Incorrect bom no added for non-variant items on variant boms ([#26320](https://github.com/frappe/erpnext/pull/26320)) +- Incorrect discount amount on amended document ([#26466](https://github.com/frappe/erpnext/pull/26466)) +- Added a message to enable appointment booking if disabled ([#26334](https://github.com/frappe/erpnext/pull/26334)) +- fix(pos): taxes amount in pos item cart ([#26411](https://github.com/frappe/erpnext/pull/26411)) +- Track changes on batch ([#26382](https://github.com/frappe/erpnext/pull/26382)) +- Stock entry with putaway rule not working ([#26350](https://github.com/frappe/erpnext/pull/26350)) +- Only "Tax" type accounts should be shown for selection in GST Settings ([#26300](https://github.com/frappe/erpnext/pull/26300)) +- Added permission for employee to book appointment ([#26255](https://github.com/frappe/erpnext/pull/26255)) +- Allow to make job card without employee ([#26312](https://github.com/frappe/erpnext/pull/26312)) +- Project Portal Enhancements ([#26290](https://github.com/frappe/erpnext/pull/26290)) +- BOM stock report not working ([#26332](https://github.com/frappe/erpnext/pull/26332)) +- Order Items by weightage in the web items query ([#26284](https://github.com/frappe/erpnext/pull/26284)) +- Removed values out of sync validation from stock transactions ([#26226](https://github.com/frappe/erpnext/pull/26226)) +- Payroll-entry minor fix ([#26349](https://github.com/frappe/erpnext/pull/26349)) +- Allow user to change the To Date in the blanket order even after submit of order ([#26241](https://github.com/frappe/erpnext/pull/26241)) +- Value fetching for custom field in POS ([#26367](https://github.com/frappe/erpnext/pull/26367)) +- Iteration through accounts only when accounts exist ([#26391](https://github.com/frappe/erpnext/pull/26391)) +- Employee Inactive status implications ([#26244](https://github.com/frappe/erpnext/pull/26244)) +- Multi-currency issue ([#26458](https://github.com/frappe/erpnext/pull/26458)) +- FG item not fetched in manufacture entry ([#26509](https://github.com/frappe/erpnext/pull/26509)) +- Set query for training events ([#26303](https://github.com/frappe/erpnext/pull/26303)) +- Fetch batch items in stock reconciliation ([#26213](https://github.com/frappe/erpnext/pull/26213)) +- Employee selection not working in payroll entry ([#26278](https://github.com/frappe/erpnext/pull/26278)) +- POS item cart dom updates (#26459) ([#26461](https://github.com/frappe/erpnext/pull/26461)) +- dunning calculation of grand total when rate of interest is 0% ([#26285](https://github.com/frappe/erpnext/pull/26285)) \ No newline at end of file diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 2fdc19e315a..cdd865ac4ac 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -116,12 +116,16 @@ class AccountsController(TransactionBase): if self.doctype == 'Purchase Invoice': self.calculate_paid_amount() + # apply tax withholding only if checked and applicable + self.set_tax_withholding() if self.doctype in ['Purchase Invoice', 'Sales Invoice']: pos_check_field = "is_pos" if self.doctype=="Sales Invoice" else "is_paid" if cint(self.allocate_advances_automatically) and not cint(self.get(pos_check_field)): self.set_advances() + self.set_advance_gain_or_loss() + if self.is_return: self.validate_qty() else: @@ -225,7 +229,7 @@ class AccountsController(TransactionBase): def validate_date_with_fiscal_year(self): if self.meta.get_field("fiscal_year"): - date_field = "" + date_field = None if self.meta.get_field("posting_date"): date_field = "posting_date" elif self.meta.get_field("transaction_date"): @@ -582,15 +586,18 @@ class AccountsController(TransactionBase): allocated_amount = min(amount - advance_allocated, d.amount) advance_allocated += flt(allocated_amount) - self.append("advances", { + advance_row = { "doctype": self.doctype + " Advance", "reference_type": d.reference_type, "reference_name": d.reference_name, "reference_row": d.reference_row, "remarks": d.remarks, "advance_amount": flt(d.amount), - "allocated_amount": allocated_amount - }) + "allocated_amount": allocated_amount, + "ref_exchange_rate": flt(d.exchange_rate) # exchange_rate of advance entry + } + + self.append("advances", advance_row) def get_advance_entries(self, include_unallocated=True): if self.doctype == "Sales Invoice": @@ -648,6 +655,66 @@ class AccountsController(TransactionBase): "Payment Entry {0} is linked against Order {1}, check if it should be pulled as advance in this invoice.") .format(d.reference_name, d.against_order)) + def set_advance_gain_or_loss(self): + if not self.get("advances"): + return + + for d in self.get("advances"): + advance_exchange_rate = d.ref_exchange_rate + if (d.allocated_amount and self.conversion_rate != 1 + and self.conversion_rate != advance_exchange_rate): + + base_allocated_amount_in_ref_rate = advance_exchange_rate * d.allocated_amount + base_allocated_amount_in_inv_rate = self.conversion_rate * d.allocated_amount + difference = base_allocated_amount_in_ref_rate - base_allocated_amount_in_inv_rate + + d.exchange_gain_loss = difference + + def make_exchange_gain_loss_gl_entries(self, gl_entries): + if self.get('doctype') in ['Purchase Invoice', 'Sales Invoice']: + for d in self.get("advances"): + if d.exchange_gain_loss: + party = self.supplier if self.get('doctype') == 'Purchase Invoice' else self.customer + party_account = self.credit_to if self.get('doctype') == 'Purchase Invoice' else self.debit_to + party_type = "Supplier" if self.get('doctype') == 'Purchase Invoice' else "Customer" + + gain_loss_account = frappe.db.get_value('Company', self.company, 'exchange_gain_loss_account') + account_currency = get_account_currency(gain_loss_account) + if account_currency != self.company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency)) + + # for purchase + dr_or_cr = 'debit' if d.exchange_gain_loss > 0 else 'credit' + # just reverse for sales? + dr_or_cr = 'debit' if dr_or_cr == 'credit' else 'credit' + + gl_entries.append( + self.get_gl_dict({ + "account": gain_loss_account, + "account_currency": account_currency, + "against": party, + dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss), + dr_or_cr: abs(d.exchange_gain_loss), + "cost_center": self.cost_center, + "project": self.project + }, item=d) + ) + + dr_or_cr = 'debit' if dr_or_cr == 'credit' else 'credit' + + gl_entries.append( + self.get_gl_dict({ + "account": party_account, + "party_type": party_type, + "party": party, + "against": gain_loss_account, + dr_or_cr + "_in_account_currency": flt(abs(d.exchange_gain_loss) / self.conversion_rate), + dr_or_cr: abs(d.exchange_gain_loss), + "cost_center": self.cost_center, + "project": self.project + }, self.party_account_currency, item=self) + ) + def update_against_document_in_jv(self): """ Links invoice and advance voucher: @@ -688,7 +755,9 @@ class AccountsController(TransactionBase): if self.party_account_currency != self.company_currency else 1), 'grand_total': (self.base_grand_total if self.party_account_currency == self.company_currency else self.grand_total), - 'outstanding_amount': self.outstanding_amount + 'outstanding_amount': self.outstanding_amount, + 'difference_account': frappe.db.get_value('Company', self.company, 'exchange_gain_loss_account'), + 'exchange_gain_loss': flt(d.get('exchange_gain_loss')) }) lst.append(args) @@ -700,6 +769,7 @@ class AccountsController(TransactionBase): from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries if self.doctype in ["Sales Invoice", "Purchase Invoice"]: + self.update_allocated_advance_taxes_on_cancel() if frappe.db.get_single_value('Accounts Settings', 'unlink_payment_on_cancellation_of_invoice'): unlink_ref_doc_from_payment_entries(self) @@ -707,6 +777,87 @@ class AccountsController(TransactionBase): if frappe.db.get_single_value('Accounts Settings', 'unlink_advance_payment_on_cancelation_of_order'): unlink_ref_doc_from_payment_entries(self) + def get_tax_map(self): + tax_map = {} + for tax in self.get('taxes'): + tax_map.setdefault(tax.account_head, 0.0) + tax_map[tax.account_head] += tax.tax_amount + + return tax_map + + def update_allocated_advance_taxes_on_cancel(self): + if self.get('advances'): + tax_accounts = [d.account_head for d in self.get('taxes')] + allocated_tax_map = frappe._dict(frappe.get_all('GL Entry', fields=['account', 'sum(credit - debit)'], + filters={'voucher_no': self.name, 'account': ('in', tax_accounts)}, + group_by='account', as_list=1)) + + tax_map = self.get_tax_map() + + for pe in self.get('advances'): + if pe.reference_type == 'Payment Entry': + pe = frappe.get_doc('Payment Entry', pe.reference_name) + for tax in pe.get('taxes'): + allocated_amount = tax_map.get(tax.account_head) - allocated_tax_map.get(tax.account_head) + if allocated_amount > tax.tax_amount: + allocated_amount = tax.tax_amount + + if allocated_amount: + frappe.db.set_value('Advance Taxes and Charges', tax.name, 'allocated_amount', + tax.allocated_amount - allocated_amount) + tax_map[tax.account_head] -= allocated_amount + allocated_tax_map[tax.account_head] -= allocated_amount + + def allocate_advance_taxes(self, gl_entries): + tax_map = self.get_tax_map() + for pe in self.get("advances"): + if pe.reference_type == "Payment Entry" and \ + frappe.db.get_value('Payment Entry', pe.reference_name, 'advance_tax_account'): + pe = frappe.get_doc("Payment Entry", pe.reference_name) + for tax in pe.get("taxes"): + account_currency = get_account_currency(tax.account_head) + + if self.doctype == "Purchase Invoice": + dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit" + rev_dr_cr = "credit" if tax.add_deduct_tax == "Add" else "debit" + else: + dr_or_cr = "credit" if tax.add_deduct_tax == "Add" else "debit" + rev_dr_cr = "debit" if tax.add_deduct_tax == "Add" else "credit" + + party = self.supplier if self.doctype == "Purchase Invoice" else self.customer + unallocated_amount = tax.tax_amount - tax.allocated_amount + if tax_map.get(tax.account_head): + amount = tax_map.get(tax.account_head) + if amount < unallocated_amount: + unallocated_amount = amount + + gl_entries.append( + self.get_gl_dict({ + "account": tax.account_head, + "against": party, + dr_or_cr: unallocated_amount, + dr_or_cr + "_in_account_currency": unallocated_amount + if account_currency==self.company_currency + else unallocated_amount, + "cost_center": tax.cost_center + }, account_currency, item=tax)) + + gl_entries.append( + self.get_gl_dict({ + "account": pe.advance_tax_account, + "against": party, + rev_dr_cr: unallocated_amount, + rev_dr_cr + "_in_account_currency": unallocated_amount + if account_currency==self.company_currency + else unallocated_amount, + "cost_center": tax.cost_center + }, account_currency, item=tax)) + + frappe.db.set_value("Advance Taxes and Charges", tax.name, "allocated_amount", + tax.allocated_amount + unallocated_amount) + + tax_map[tax.account_head] -= unallocated_amount + def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): from erpnext.controllers.status_updater import get_allowance_for item_allowance = {} @@ -744,8 +895,14 @@ class AccountsController(TransactionBase): role_allowed_to_over_bill = frappe.db.get_single_value('Accounts Settings', 'role_allowed_to_over_bill') if total_billed_amt - max_allowed_amt > 0.01 and role_allowed_to_over_bill not in frappe.get_roles(): - frappe.throw(_("Cannot overbill for Item {0} in row {1} more than {2}. To allow over-billing, please set allowance in Accounts Settings") - .format(item.item_code, item.idx, max_allowed_amt)) + if self.doctype != "Purchase Invoice": + self.throw_overbill_exception(item, max_allowed_amt) + elif not cint(frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")): + self.throw_overbill_exception(item, max_allowed_amt) + + def throw_overbill_exception(self, item, max_allowed_amt): + frappe.throw(_("Cannot overbill for Item {0} in row {1} more than {2}. To allow over-billing, please set allowance in Accounts Settings") + .format(item.item_code, item.idx, max_allowed_amt)) def get_company_default(self, fieldname): from erpnext.accounts.utils import get_company_default @@ -955,8 +1112,11 @@ class AccountsController(TransactionBase): for d in self.get("payment_schedule"): if d.invoice_portion: d.payment_amount = flt(grand_total * flt(d.invoice_portion / 100), d.precision('payment_amount')) - d.base_payment_amount = flt(base_grand_total * flt(d.invoice_portion / 100), d.precision('payment_amount')) + d.base_payment_amount = flt(base_grand_total * flt(d.invoice_portion / 100), d.precision('base_payment_amount')) d.outstanding = d.payment_amount + elif not d.invoice_portion: + d.base_payment_amount = flt(base_grand_total * self.get("conversion_rate"), d.precision('base_payment_amount')) + def set_due_date(self): due_dates = [d.due_date for d in self.get("payment_schedule") if d.due_date] @@ -1108,7 +1268,7 @@ def validate_conversion_rate(currency, conversion_rate, conversion_rate_label, c def validate_taxes_and_charges(tax): - if tax.charge_type in ['Actual', 'On Net Total'] and tax.row_id: + if tax.charge_type in ['Actual', 'On Net Total', 'On Paid Amount'] and tax.row_id: frappe.throw(_("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'")) elif tax.charge_type in ['On Previous Row Amount', 'On Previous Row Total']: if cint(tax.idx) == 1: @@ -1125,20 +1285,19 @@ def validate_taxes_and_charges(tax): def validate_inclusive_tax(tax, doc): def _on_previous_row_error(row_range): - throw(_("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(tax.idx, - row_range)) + throw(_("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(tax.idx, row_range)) if cint(getattr(tax, "included_in_print_rate", None)): if tax.charge_type == "Actual": # inclusive tax cannot be of type Actual - throw(_("Charge of type 'Actual' in row {0} cannot be included in Item Rate").format(tax.idx)) + throw(_("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format(tax.idx)) elif tax.charge_type == "On Previous Row Amount" and \ not cint(doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate): # referred row should also be inclusive _on_previous_row_error(tax.row_id) elif tax.charge_type == "On Previous Row Total" and \ not all([cint(t.included_in_print_rate) for t in doc.get("taxes")[:cint(tax.row_id) - 1]]): - # all rows about the reffered tax should be inclusive + # all rows about the referred tax should be inclusive _on_previous_row_error("1 - %d" % (tax.row_id,)) elif tax.get("category") == "Valuation": frappe.throw(_("Valuation type charges can not be marked as Inclusive")) @@ -1200,6 +1359,8 @@ def get_advance_payment_entries(party_type, party, party_account, order_doctype, party_account_field = "paid_from" if party_type == "Customer" else "paid_to" currency_field = "paid_from_account_currency" if party_type == "Customer" else "paid_to_account_currency" payment_type = "Receive" if party_type == "Customer" else "Pay" + exchange_rate_field = "source_exchange_rate" if payment_type == "Receive" else "target_exchange_rate" + payment_entries_against_order, unallocated_payment_entries = [], [] limit_cond = "limit %s" % limit if limit else "" @@ -1216,31 +1377,31 @@ def get_advance_payment_entries(party_type, party, party_account, order_doctype, "Payment Entry" as reference_type, t1.name as reference_name, t1.remarks, t2.allocated_amount as amount, t2.name as reference_row, t2.reference_name as against_order, t1.posting_date, - t1.{0} as currency + t1.{0} as currency, t1.{4} as exchange_rate from `tabPayment Entry` t1, `tabPayment Entry Reference` t2 where t1.name = t2.parent and t1.{1} = %s and t1.payment_type = %s and t1.party_type = %s and t1.party = %s and t1.docstatus = 1 and t2.reference_doctype = %s {2} order by t1.posting_date {3} - """.format(currency_field, party_account_field, reference_condition, limit_cond), + """.format(currency_field, party_account_field, reference_condition, limit_cond, exchange_rate_field), [party_account, payment_type, party_type, party, order_doctype] + order_list, as_dict=1) if include_unallocated: unallocated_payment_entries = frappe.db.sql(""" select "Payment Entry" as reference_type, name as reference_name, - remarks, unallocated_amount as amount + remarks, unallocated_amount as amount, {2} as exchange_rate from `tabPayment Entry` where {0} = %s and party_type = %s and party = %s and payment_type = %s and docstatus = 1 and unallocated_amount > 0 order by posting_date {1} - """.format(party_account_field, limit_cond), (party_account, party_type, party, payment_type), as_dict=1) + """.format(party_account_field, limit_cond, exchange_rate_field), + (party_account, party_type, party, payment_type), as_dict=1) return list(payment_entries_against_order) + list(unallocated_payment_entries) - def update_invoice_status(): # Daily update the status of the invoices @@ -1449,6 +1610,7 @@ def validate_and_delete_children(parent, data): for d in deleted_children: update_bin_on_delete(d, parent.doctype) + @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): def check_doc_permissions(doc, perm_type='create'): diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index da819119b10..6a550e0e975 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -11,16 +11,17 @@ from erpnext.accounts.party import get_party_details from erpnext.stock.get_item_details import get_conversion_factor from erpnext.buying.utils import validate_for_items, update_last_purchase_rate from erpnext.stock.stock_ledger import get_valuation_rate -from erpnext.stock.doctype.stock_entry.stock_entry import get_used_alternative_items from erpnext.stock.doctype.serial_no.serial_no import get_auto_serial_nos, auto_make_serial_nos, get_serial_nos from frappe.contacts.doctype.address.address import get_address_display from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget -from erpnext.controllers.stock_controller import StockController from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.stock.utils import get_incoming_rate -class BuyingController(StockController): +from erpnext.controllers.stock_controller import StockController +from erpnext.controllers.subcontracting import Subcontracting + +class BuyingController(StockController, Subcontracting): def get_feed(self): if self.get("supplier_name"): @@ -57,6 +58,11 @@ class BuyingController(StockController): if self.doctype in ("Purchase Receipt", "Purchase Invoice"): self.update_valuation_rate() + def onload(self): + super(BuyingController, self).onload() + self.set_onload("backflush_based_on", frappe.db.get_single_value('Buying Settings', + 'backflush_raw_materials_of_subcontract_based_on')) + def set_missing_values(self, for_validate=False): super(BuyingController, self).set_missing_values(for_validate) @@ -171,12 +177,13 @@ class BuyingController(StockController): TODO: rename item_tax_amount to valuation_tax_amount """ + stock_and_asset_items = [] stock_and_asset_items = self.get_stock_items() + self.get_asset_items() stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0 last_item_idx = 1 for d in self.get("items"): - if d.item_code and d.item_code in stock_and_asset_items: + if (d.item_code and d.item_code in stock_and_asset_items): stock_and_asset_items_qty += flt(d.qty) stock_and_asset_items_amount += flt(d.base_net_amount) last_item_idx = d.idx @@ -255,7 +262,7 @@ class BuyingController(StockController): supplied_items_cost = 0.0 for d in self.get("supplied_items"): if d.reference_name == item_row_id: - if reset_outgoing_rate and frappe.db.get_value('Item', d.rm_item_code, 'is_stock_item'): + if reset_outgoing_rate and frappe.get_cached_value('Item', d.rm_item_code, 'is_stock_item'): rate = get_incoming_rate({ "item_code": d.rm_item_code, "warehouse": self.supplier_warehouse, @@ -285,11 +292,13 @@ class BuyingController(StockController): if item in self.sub_contracted_items and not item.bom: frappe.throw(_("Please select BOM in BOM field for Item {0}").format(item.item_code)) - if self.doctype == "Purchase Order": - for supplied_item in self.get("supplied_items"): - if not supplied_item.reserve_warehouse: - frappe.throw(_("Reserved Warehouse is mandatory for Item {0} in Raw Materials supplied").format(frappe.bold(supplied_item.rm_item_code))) + if self.doctype != "Purchase Order": + return + for row in self.get("supplied_items"): + if not row.reserve_warehouse: + msg = f"Reserved Warehouse is mandatory for the Item {frappe.bold(row.rm_item_code)} in Raw Materials supplied" + frappe.throw(_(msg)) else: for item in self.get("items"): if item.bom: @@ -297,23 +306,7 @@ class BuyingController(StockController): def create_raw_materials_supplied(self, raw_material_table): if self.is_subcontracted=="Yes": - parent_items = [] - backflush_raw_materials_based_on = frappe.db.get_single_value("Buying Settings", - "backflush_raw_materials_of_subcontract_based_on") - if (self.doctype == 'Purchase Receipt' and - backflush_raw_materials_based_on != 'BOM'): - self.update_raw_materials_supplied_based_on_stock_entries() - else: - for item in self.get("items"): - if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: - item.rm_supp_cost = 0.0 - if item.bom and item.item_code in self.sub_contracted_items: - self.update_raw_materials_supplied_based_on_bom(item, raw_material_table) - - if [item.item_code, item.name] not in parent_items: - parent_items.append([item.item_code, item.name]) - - self.cleanup_raw_materials_supplied(parent_items, raw_material_table) + self.set_materials_for_subcontracted_items(raw_material_table) elif self.doctype in ["Purchase Receipt", "Purchase Invoice"]: for item in self.get("items"): @@ -322,176 +315,6 @@ class BuyingController(StockController): if self.is_subcontracted == "No" and self.get("supplied_items"): self.set('supplied_items', []) - def update_raw_materials_supplied_based_on_stock_entries(self): - self.set('supplied_items', []) - - purchase_orders = set(d.purchase_order for d in self.items) - - # qty of raw materials backflushed (for each item per purchase order) - backflushed_raw_materials_map = get_backflushed_subcontracted_raw_materials(purchase_orders) - - # qty of "finished good" item yet to be received - qty_to_be_received_map = get_qty_to_be_received(purchase_orders) - - for item in self.get('items'): - if not item.purchase_order: - continue - - # reset raw_material cost - item.rm_supp_cost = 0 - - # qty of raw materials transferred to the supplier - transferred_raw_materials = get_subcontracted_raw_materials_from_se(item.purchase_order, item.item_code) - - non_stock_items = get_non_stock_items(item.purchase_order, item.item_code) - - item_key = '{}{}'.format(item.item_code, item.purchase_order) - - fg_yet_to_be_received = qty_to_be_received_map.get(item_key) - - if not fg_yet_to_be_received: - frappe.throw(_("Row #{0}: Item {1} is already fully received in Purchase Order {2}") - .format(item.idx, frappe.bold(item.item_code), - frappe.utils.get_link_to_form("Purchase Order", item.purchase_order)), - title=_("Limit Crossed")) - - transferred_batch_qty_map = get_transferred_batch_qty_map(item.purchase_order, item.item_code) - # backflushed_batch_qty_map = get_backflushed_batch_qty_map(item.purchase_order, item.item_code) - - for raw_material in transferred_raw_materials + non_stock_items: - rm_item_key = (raw_material.rm_item_code, item.item_code, item.purchase_order) - raw_material_data = backflushed_raw_materials_map.get(rm_item_key, {}) - - consumed_qty = raw_material_data.get('qty', 0) - consumed_serial_nos = raw_material_data.get('serial_no', '') - consumed_batch_nos = raw_material_data.get('batch_nos', '') - - transferred_qty = raw_material.qty - - rm_qty_to_be_consumed = transferred_qty - consumed_qty - - # backflush all remaining transferred qty in the last Purchase Receipt - if fg_yet_to_be_received == item.qty: - qty = rm_qty_to_be_consumed - else: - qty = (rm_qty_to_be_consumed / fg_yet_to_be_received) * item.qty - - if frappe.get_cached_value('UOM', raw_material.stock_uom, 'must_be_whole_number'): - qty = frappe.utils.ceil(qty) - - if qty > rm_qty_to_be_consumed: - qty = rm_qty_to_be_consumed - - if not qty: continue - - if raw_material.serial_nos: - set_serial_nos(raw_material, consumed_serial_nos, qty) - - if raw_material.batch_nos: - backflushed_batch_qty_map = raw_material_data.get('consumed_batch', {}) - - batches_qty = get_batches_with_qty(raw_material.rm_item_code, raw_material.main_item_code, - qty, transferred_batch_qty_map, backflushed_batch_qty_map, item.purchase_order) - - for batch_data in batches_qty: - qty = batch_data['qty'] - raw_material.batch_no = batch_data['batch'] - if qty > 0: - self.append_raw_material_to_be_backflushed(item, raw_material, qty) - else: - self.append_raw_material_to_be_backflushed(item, raw_material, qty) - - def append_raw_material_to_be_backflushed(self, fg_item_row, raw_material_data, qty): - rm = self.append('supplied_items', {}) - rm.update(raw_material_data) - - if not rm.main_item_code: - rm.main_item_code = fg_item_row.item_code - - rm.reference_name = fg_item_row.name - rm.required_qty = qty - rm.consumed_qty = qty - - def update_raw_materials_supplied_based_on_bom(self, item, raw_material_table): - exploded_item = 1 - if hasattr(item, 'include_exploded_items'): - exploded_item = item.get('include_exploded_items') - - bom_items = get_items_from_bom(item.item_code, item.bom, exploded_item) - - used_alternative_items = [] - if self.doctype in ["Purchase Receipt", "Purchase Invoice"] and item.purchase_order: - used_alternative_items = get_used_alternative_items(purchase_order = item.purchase_order) - - raw_materials_cost = 0 - items = list(set([d.item_code for d in bom_items])) - item_wh = frappe._dict(frappe.db.sql("""select i.item_code, id.default_warehouse - from `tabItem` i, `tabItem Default` id - where id.parent=i.name and id.company=%s and i.name in ({0})""" - .format(", ".join(["%s"] * len(items))), [self.company] + items)) - - for bom_item in bom_items: - if self.doctype == "Purchase Order": - reserve_warehouse = bom_item.source_warehouse or item_wh.get(bom_item.item_code) - if frappe.db.get_value("Warehouse", reserve_warehouse, "company") != self.company: - reserve_warehouse = None - - conversion_factor = item.conversion_factor - if (self.doctype in ["Purchase Receipt", "Purchase Invoice"] and item.purchase_order and - bom_item.item_code in used_alternative_items): - alternative_item_data = used_alternative_items.get(bom_item.item_code) - bom_item.item_code = alternative_item_data.item_code - bom_item.item_name = alternative_item_data.item_name - bom_item.stock_uom = alternative_item_data.stock_uom - conversion_factor = alternative_item_data.conversion_factor - bom_item.description = alternative_item_data.description - - # check if exists - exists = 0 - for d in self.get(raw_material_table): - if d.main_item_code == item.item_code and d.rm_item_code == bom_item.item_code \ - and d.reference_name == item.name: - rm, exists = d, 1 - break - - if not exists: - rm = self.append(raw_material_table, {}) - - required_qty = flt(flt(bom_item.qty_consumed_per_unit) * (flt(item.qty) + getattr(item, 'rejected_qty', 0)) * - flt(conversion_factor), rm.precision("required_qty")) - rm.reference_name = item.name - rm.bom_detail_no = bom_item.name - rm.main_item_code = item.item_code - rm.rm_item_code = bom_item.item_code - rm.stock_uom = bom_item.stock_uom - rm.required_qty = required_qty - rm.rate = bom_item.rate - rm.conversion_factor = conversion_factor - - if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: - rm.consumed_qty = required_qty - rm.description = bom_item.description - if item.batch_no and frappe.db.get_value("Item", rm.rm_item_code, "has_batch_no") and not rm.batch_no: - rm.batch_no = item.batch_no - elif not rm.reserve_warehouse: - rm.reserve_warehouse = reserve_warehouse - - def cleanup_raw_materials_supplied(self, parent_items, raw_material_table): - """Remove all those child items which are no longer present in main item table""" - delete_list = [] - for d in self.get(raw_material_table): - if [d.main_item_code, d.reference_name] not in parent_items: - # mark for deletion from doclist - delete_list.append(d) - - # delete from doclist - if delete_list: - rm_supplied_details = self.get(raw_material_table) - self.set(raw_material_table, []) - for d in rm_supplied_details: - if d not in delete_list: - self.append(raw_material_table, d) - @property def sub_contracted_items(self): if not hasattr(self, "_sub_contracted_items"): @@ -683,7 +506,8 @@ class BuyingController(StockController): self.process_fixed_asset() self.update_fixed_asset(field) - update_last_purchase_rate(self, is_submit = 1) + if self.doctype in ['Purchase Order', 'Purchase Receipt']: + update_last_purchase_rate(self, is_submit = 1) def on_cancel(self): super(BuyingController, self).on_cancel() @@ -691,7 +515,9 @@ class BuyingController(StockController): if self.get('is_return'): return - update_last_purchase_rate(self, is_submit = 0) + if self.doctype in ['Purchase Order', 'Purchase Receipt']: + update_last_purchase_rate(self, is_submit = 0) + if self.doctype in ['Purchase Receipt', 'Purchase Invoice']: field = 'purchase_invoice' if self.doctype == 'Purchase Invoice' else 'purchase_receipt' @@ -863,104 +689,6 @@ class BuyingController(StockController): else: validate_item_type(self, "is_purchase_item", "purchase") - -def get_items_from_bom(item_code, bom, exploded_item=1): - doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" - - bom_items = frappe.db.sql("""select t2.item_code, t2.name, - t2.rate, t2.stock_uom, t2.source_warehouse, t2.description, - t2.stock_qty / ifnull(t1.quantity, 1) as qty_consumed_per_unit - from - `tabBOM` t1, `tab{0}` t2, tabItem t3 - where - t2.parent = t1.name and t1.item = %s - and t1.docstatus = 1 and t1.is_active = 1 and t1.name = %s - and t2.sourced_by_supplier = 0 - and t2.item_code = t3.name""".format(doctype), - (item_code, bom), as_dict=1) - - if not bom_items: - msgprint(_("Specified BOM {0} does not exist for Item {1}").format(bom, item_code), raise_exception=1) - - return bom_items - -def get_subcontracted_raw_materials_from_se(purchase_order, fg_item): - common_query = """ - SELECT - sed.item_code AS rm_item_code, - SUM(sed.qty) AS qty, - sed.description, - sed.stock_uom, - sed.subcontracted_item AS main_item_code, - {serial_no_concat_syntax} AS serial_nos, - {batch_no_concat_syntax} AS batch_nos - FROM `tabStock Entry` se,`tabStock Entry Detail` sed - WHERE - se.name = sed.parent - AND se.docstatus=1 - AND se.purpose='Send to Subcontractor' - AND se.purchase_order = %s - AND IFNULL(sed.t_warehouse, '') != '' - AND IFNULL(sed.subcontracted_item, '') in ('', %s) - GROUP BY sed.item_code, sed.subcontracted_item - """ - raw_materials = frappe.db.multisql({ - 'mariadb': common_query.format( - serial_no_concat_syntax="GROUP_CONCAT(sed.serial_no)", - batch_no_concat_syntax="GROUP_CONCAT(sed.batch_no)" - ), - 'postgres': common_query.format( - serial_no_concat_syntax="STRING_AGG(sed.serial_no, ',')", - batch_no_concat_syntax="STRING_AGG(sed.batch_no, ',')" - ) - }, (purchase_order, fg_item), as_dict=1) - - return raw_materials - -def get_backflushed_subcontracted_raw_materials(purchase_orders): - purchase_receipts = frappe.get_all("Purchase Receipt Item", - fields = ["purchase_order", "item_code", "name", "parent"], - filters={"docstatus": 1, "purchase_order": ("in", list(purchase_orders))}) - - distinct_purchase_receipts = {} - for pr in purchase_receipts: - key = (pr.purchase_order, pr.item_code, pr.parent) - distinct_purchase_receipts.setdefault(key, []).append(pr.name) - - backflushed_raw_materials_map = frappe._dict() - for args, references in iteritems(distinct_purchase_receipts): - purchase_receipt_supplied_items = get_supplied_items(args[1], args[2], references) - - for data in purchase_receipt_supplied_items: - pr_key = (data.rm_item_code, data.main_item_code, args[0]) - if pr_key not in backflushed_raw_materials_map: - backflushed_raw_materials_map.setdefault(pr_key, frappe._dict({ - "qty": 0.0, - "serial_no": [], - "batch_no": [], - "consumed_batch": {} - })) - - row = backflushed_raw_materials_map.get(pr_key) - row.qty += data.consumed_qty - - for field in ["serial_no", "batch_no"]: - if data.get(field): - row[field].append(data.get(field)) - - if data.get("batch_no"): - if data.get("batch_no") in row.consumed_batch: - row.consumed_batch[data.get("batch_no")] += data.consumed_qty - else: - row.consumed_batch[data.get("batch_no")] = data.consumed_qty - - return backflushed_raw_materials_map - -def get_supplied_items(item_code, purchase_receipt, references): - return frappe.get_all("Purchase Receipt Item Supplied", - fields=["rm_item_code", "main_item_code", "consumed_qty", "serial_no", "batch_no"], - filters={"main_item_code": item_code, "parent": purchase_receipt, "reference_name": ("in", references)}) - def get_asset_item_details(asset_items): asset_items_data = {} for d in frappe.get_all('Item', fields = ["name", "auto_create_assets", "asset_naming_series"], @@ -992,135 +720,3 @@ def validate_item_type(doc, fieldname, message): error_message = _("Following item {0} is not marked as {1} item. You can enable them as {1} item from its Item master").format(items, message) frappe.throw(error_message) - -def get_qty_to_be_received(purchase_orders): - return frappe._dict(frappe.db.sql(""" - SELECT CONCAT(poi.`item_code`, poi.`parent`) AS item_key, - SUM(poi.`qty`) - SUM(poi.`received_qty`) AS qty_to_be_received - FROM `tabPurchase Order Item` poi - WHERE - poi.`parent` in %s - GROUP BY poi.`item_code`, poi.`parent` - HAVING SUM(poi.`qty`) > SUM(poi.`received_qty`) - """, (purchase_orders))) - -def get_non_stock_items(purchase_order, fg_item_code): - return frappe.db.sql(""" - SELECT - pois.main_item_code, - pois.rm_item_code, - item.description, - pois.required_qty AS qty, - pois.rate, - 1 as non_stock_item, - pois.stock_uom - FROM `tabPurchase Order Item Supplied` pois, `tabItem` item - WHERE - pois.`rm_item_code` = item.`name` - AND item.is_stock_item = 0 - AND pois.`parent` = %s - AND pois.`main_item_code` = %s - """, (purchase_order, fg_item_code), as_dict=1) - - -def set_serial_nos(raw_material, consumed_serial_nos, qty): - serial_nos = set(get_serial_nos(raw_material.serial_nos)) - \ - set(get_serial_nos(consumed_serial_nos)) - if serial_nos and qty <= len(serial_nos): - raw_material.serial_no = '\n'.join(list(serial_nos)[0:frappe.utils.cint(qty)]) - -def get_transferred_batch_qty_map(purchase_order, fg_item): - # returns - # { - # (item_code, fg_code): { - # batch1: 10, # qty - # batch2: 16 - # }, - # } - transferred_batch_qty_map = {} - transferred_batches = frappe.db.sql(""" - SELECT - sed.batch_no, - SUM(sed.qty) AS qty, - sed.item_code, - sed.subcontracted_item - FROM `tabStock Entry` se,`tabStock Entry Detail` sed - WHERE - se.name = sed.parent - AND se.docstatus=1 - AND se.purpose='Send to Subcontractor' - AND se.purchase_order = %s - AND ifnull(sed.subcontracted_item, '') in ('', %s) - AND sed.batch_no IS NOT NULL - GROUP BY - sed.batch_no, - sed.item_code - """, (purchase_order, fg_item), as_dict=1) - - for batch_data in transferred_batches: - key = ((batch_data.item_code, fg_item) - if batch_data.subcontracted_item else (batch_data.item_code, purchase_order)) - transferred_batch_qty_map.setdefault(key, OrderedDict()) - transferred_batch_qty_map[key][batch_data.batch_no] = batch_data.qty - - return transferred_batch_qty_map - -def get_backflushed_batch_qty_map(purchase_order, fg_item): - # returns - # { - # (item_code, fg_code): { - # batch1: 10, # qty - # batch2: 16 - # }, - # } - backflushed_batch_qty_map = {} - backflushed_batches = frappe.db.sql(""" - SELECT - pris.batch_no, - SUM(pris.consumed_qty) AS qty, - pris.rm_item_code AS item_code - FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri, `tabPurchase Receipt Item Supplied` pris - WHERE - pr.name = pri.parent - AND pri.parent = pris.parent - AND pri.purchase_order = %s - AND pri.item_code = pris.main_item_code - AND pr.docstatus = 1 - AND pris.main_item_code = %s - AND pris.batch_no IS NOT NULL - GROUP BY - pris.rm_item_code, pris.batch_no - """, (purchase_order, fg_item), as_dict=1) - - for batch_data in backflushed_batches: - backflushed_batch_qty_map.setdefault((batch_data.item_code, fg_item), {}) - backflushed_batch_qty_map[(batch_data.item_code, fg_item)][batch_data.batch_no] = batch_data.qty - - return backflushed_batch_qty_map - -def get_batches_with_qty(item_code, fg_item, required_qty, transferred_batch_qty_map, backflushed_batches, po): - # Returns available batches to be backflushed based on requirements - transferred_batches = transferred_batch_qty_map.get((item_code, fg_item), {}) - if not transferred_batches: - transferred_batches = transferred_batch_qty_map.get((item_code, po), {}) - - available_batches = [] - - for (batch, transferred_qty) in transferred_batches.items(): - backflushed_qty = backflushed_batches.get(batch, 0) - available_qty = transferred_qty - backflushed_qty - - if available_qty >= required_qty: - available_batches.append({'batch': batch, 'qty': required_qty}) - break - elif available_qty != 0: - available_batches.append({'batch': batch, 'qty': available_qty}) - required_qty -= available_qty - - for row in available_batches: - if backflushed_batches.get(row.get('batch'), 0) > 0: - backflushed_batches[row.get('batch')] += row.get('qty') - else: - backflushed_batches[row.get('batch')] = row.get('qty') - - return available_batches diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 7bd739a6ad3..280319321f2 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -19,7 +19,7 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): fields = get_fields("Employee", ["name", "employee_name"]) return frappe.db.sql("""select {fields} from `tabEmployee` - where status = 'Active' + where status in ('Active', 'Suspended') and docstatus < 2 and ({key} like %(txt)s or employee_name like %(txt)s) @@ -315,7 +315,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql("""select {fields} from `tabProject` where `tabProject`.status not in ("Completed", "Cancelled") - and {cond} {match_cond} {scond} + and {cond} {scond} {match_cond} order by if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), idx desc, diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 5f759b43bc6..80ccc6d75b2 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -99,9 +99,10 @@ def validate_returned_items(doc): frappe.throw(_("Row # {0}: Serial No {1} does not match with {2} {3}") .format(d.idx, s, doc.doctype, doc.return_against)) - if warehouse_mandatory and frappe.db.get_value("Item", d.item_code, "is_stock_item") \ - and not d.get("warehouse"): - frappe.throw(_("Warehouse is mandatory")) + if (warehouse_mandatory and not d.get("warehouse") and + frappe.db.get_value("Item", d.item_code, "is_stock_item") + ): + frappe.throw(_("Warehouse is mandatory")) items_returned = True @@ -462,4 +463,4 @@ def get_returned_serial_nos(child_doc, parent_doc): for row in frappe.get_all(parent_doc.doctype, fields = fields, filters=filters): serial_nos.extend(get_serial_nos(row.serial_no)) - return serial_nos \ No newline at end of file + return serial_nos diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 7f28289760c..da2765deded 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -330,9 +330,15 @@ class SellingController(StockController): # For internal transfers use incoming rate as the valuation rate if self.is_internal_transfer(): - rate = flt(d.incoming_rate * d.conversion_factor, d.precision('rate')) - if d.rate != rate: - d.rate = rate + if d.doctype == "Packed Item": + incoming_rate = flt(d.incoming_rate * d.conversion_factor, d.precision('incoming_rate')) + if d.incoming_rate != incoming_rate: + d.incoming_rate = incoming_rate + else: + rate = flt(d.incoming_rate * d.conversion_factor, d.precision('rate')) + if d.rate != rate: + d.rate = rate + d.discount_percentage = 0 d.discount_amount = 0 frappe.msgprint(_("Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer") diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 71bde7fad73..17bd7354f93 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1,17 +1,21 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals -import frappe, erpnext -from frappe.utils import cint, flt, cstr, get_link_to_form, today, getdate -from frappe import _ -import frappe.defaults +import json from collections import defaultdict -from erpnext.accounts.utils import get_fiscal_year, check_if_stock_and_account_balance_synced + +import frappe +import frappe.defaults +from frappe import _ +from frappe.utils import cint, cstr, flt, get_link_to_form, getdate + +import erpnext from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries, process_gl_map +from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController -from erpnext.stock.stock_ledger import get_valuation_rate from erpnext.stock import get_warehouse_account_map +from erpnext.stock.stock_ledger import get_valuation_rate + class QualityInspectionRequiredError(frappe.ValidationError): pass class QualityInspectionRejectedError(frappe.ValidationError): pass @@ -49,12 +53,17 @@ class StockController(AccountsController): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos for d in self.get("items"): if hasattr(d, 'serial_no') and hasattr(d, 'batch_no') and d.serial_no and d.batch_no: - serial_nos = get_serial_nos(d.serial_no) - for serial_no_data in frappe.get_all("Serial No", - filters={"name": ("in", serial_nos)}, fields=["batch_no", "name"]): - if serial_no_data.batch_no != d.batch_no: + serial_nos = frappe.get_all("Serial No", + fields=["batch_no", "name", "warehouse"], + filters={ + "name": ("in", get_serial_nos(d.serial_no)) + } + ) + + for row in serial_nos: + if row.warehouse and row.batch_no != d.batch_no: frappe.throw(_("Row #{0}: Serial No {1} does not belong to Batch {2}") - .format(d.idx, serial_no_data.name, d.batch_no)) + .format(d.idx, row.name, d.batch_no)) if flt(d.qty) > 0.0 and d.get("batch_no") and self.get("posting_date") and self.docstatus < 2: expiry_date = frappe.get_cached_value("Batch", d.get("batch_no"), "expiry_date") @@ -189,7 +198,6 @@ class StockController(AccountsController): if hasattr(self, "items"): item_doclist = self.get("items") elif self.doctype == "Stock Reconciliation": - import json item_doclist = [] data = json.loads(self.reconciliation_json) for row in data[data.index(self.head_row)+1:]: @@ -319,7 +327,7 @@ class StockController(AccountsController): return serialized_items def validate_warehouse(self): - from erpnext.stock.utils import validate_warehouse_company, validate_disabled_warehouse + from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company warehouses = list(set(d.warehouse for d in self.get("items") if getattr(d, "warehouse", None))) @@ -353,42 +361,68 @@ class StockController(AccountsController): }, update_modified) def validate_inspection(self): - '''Checks if quality inspection is set for Items that require inspection. - On submit, throw an exception''' - inspection_required_fieldname = None - if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: - inspection_required_fieldname = "inspection_required_before_purchase" - elif self.doctype in ["Delivery Note", "Sales Invoice"]: - inspection_required_fieldname = "inspection_required_before_delivery" + """Checks if quality inspection is set/ is valid for Items that require inspection.""" + inspection_fieldname_map = { + "Purchase Receipt": "inspection_required_before_purchase", + "Purchase Invoice": "inspection_required_before_purchase", + "Sales Invoice": "inspection_required_before_delivery", + "Delivery Note": "inspection_required_before_delivery" + } + inspection_required_fieldname = inspection_fieldname_map.get(self.doctype) + # return if inspection is not required on document level if ((not inspection_required_fieldname and self.doctype != "Stock Entry") or (self.doctype == "Stock Entry" and not self.inspection_required) or (self.doctype in ["Sales Invoice", "Purchase Invoice"] and not self.update_stock)): return - for d in self.get('items'): - qa_required = False - if (inspection_required_fieldname and not d.quality_inspection and - frappe.db.get_value("Item", d.item_code, inspection_required_fieldname)): - qa_required = True - elif self.doctype == "Stock Entry" and not d.quality_inspection and d.t_warehouse: - qa_required = True - if self.docstatus == 1 and d.quality_inspection: - qa_doc = frappe.get_doc("Quality Inspection", d.quality_inspection) - if qa_doc.docstatus == 0: - link = frappe.utils.get_link_to_form('Quality Inspection', d.quality_inspection) - frappe.throw(_("Quality Inspection: {0} is not submitted for the item: {1} in row {2}").format(link, d.item_code, d.idx), QualityInspectionNotSubmittedError) + for row in self.get('items'): + qi_required = False + if (inspection_required_fieldname and frappe.db.get_value("Item", row.item_code, inspection_required_fieldname)): + qi_required = True + elif self.doctype == "Stock Entry" and row.t_warehouse: + qi_required = True # inward stock needs inspection - if qa_doc.status != 'Accepted': - frappe.throw(_("Row {0}: Quality Inspection rejected for item {1}") - .format(d.idx, d.item_code), QualityInspectionRejectedError) - elif qa_required : - action = frappe.get_doc('Stock Settings').action_if_quality_inspection_is_not_submitted - if self.docstatus==1 and action == 'Stop': - frappe.throw(_("Quality Inspection required for Item {0} to submit").format(frappe.bold(d.item_code)), - exc=QualityInspectionRequiredError) - else: - frappe.msgprint(_("Create Quality Inspection for Item {0}").format(frappe.bold(d.item_code))) + if qi_required: # validate row only if inspection is required on item level + self.validate_qi_presence(row) + if self.docstatus == 1: + self.validate_qi_submission(row) + self.validate_qi_rejection(row) + + def validate_qi_presence(self, row): + """Check if QI is present on row level. Warn on save and stop on submit if missing.""" + if not row.quality_inspection: + msg = f"Row #{row.idx}: Quality Inspection is required for Item {frappe.bold(row.item_code)}" + if self.docstatus == 1: + frappe.throw(_(msg), title=_("Inspection Required"), exc=QualityInspectionRequiredError) + else: + frappe.msgprint(_(msg), title=_("Inspection Required"), indicator="blue") + + def validate_qi_submission(self, row): + """Check if QI is submitted on row level, during submission""" + action = frappe.db.get_single_value("Stock Settings", "action_if_quality_inspection_is_not_submitted") + qa_docstatus = frappe.db.get_value("Quality Inspection", row.quality_inspection, "docstatus") + + if not qa_docstatus == 1: + link = frappe.utils.get_link_to_form('Quality Inspection', row.quality_inspection) + msg = f"Row #{row.idx}: Quality Inspection {link} is not submitted for the item: {row.item_code}" + if action == "Stop": + frappe.throw(_(msg), title=_("Inspection Submission"), exc=QualityInspectionNotSubmittedError) + else: + frappe.msgprint(_(msg), alert=True, indicator="orange") + + def validate_qi_rejection(self, row): + """Check if QI is rejected on row level, during submission""" + action = frappe.db.get_single_value("Stock Settings", "action_if_quality_inspection_is_rejected") + qa_status = frappe.db.get_value("Quality Inspection", row.quality_inspection, "status") + + if qa_status == "Rejected": + link = frappe.utils.get_link_to_form('Quality Inspection', row.quality_inspection) + msg = f"Row #{row.idx}: Quality Inspection {link} was rejected for item {row.item_code}" + if action == "Stop": + frappe.throw(_(msg), title=_("Inspection Rejected"), exc=QualityInspectionRejectedError) + else: + frappe.msgprint(_(msg), alert=True, indicator="orange") def update_blanket_order(self): blanket_orders = list(set([d.blanket_order for d in self.items if d.blanket_order])) @@ -494,24 +528,107 @@ class StockController(AccountsController): }) if future_sle_exists(args): create_repost_item_valuation_entry(args) - elif not is_reposting_pending(): - check_if_stock_and_account_balance_synced(self.posting_date, - self.company, self.doctype, self.name) + +@frappe.whitelist() +def make_quality_inspections(doctype, docname, items): + if isinstance(items, str): + items = json.loads(items) + + inspections = [] + for item in items: + if flt(item.get("sample_size")) > flt(item.get("qty")): + frappe.throw(_("{item_name}'s Sample Size ({sample_size}) cannot be greater than the Accepted Quantity ({accepted_quantity})").format( + item_name=item.get("item_name"), + sample_size=item.get("sample_size"), + accepted_quantity=item.get("qty") + )) + + quality_inspection = frappe.get_doc({ + "doctype": "Quality Inspection", + "inspection_type": "Incoming", + "inspected_by": frappe.session.user, + "reference_type": doctype, + "reference_name": docname, + "item_code": item.get("item_code"), + "description": item.get("description"), + "sample_size": flt(item.get("sample_size")), + "item_serial_no": item.get("serial_no").split("\n")[0] if item.get("serial_no") else None, + "batch_no": item.get("batch_no") + }).insert() + quality_inspection.save() + inspections.append(quality_inspection.name) + + return inspections def is_reposting_pending(): return frappe.db.exists("Repost Item Valuation", {'docstatus': 1, 'status': ['in', ['Queued','In Progress']]}) +def future_sle_exists(args, sl_entries=None): + key = (args.voucher_type, args.voucher_no) -def future_sle_exists(args): - sl_entries = frappe.get_all("Stock Ledger Entry", + if validate_future_sle_not_exists(args, key, sl_entries): + return False + elif get_cached_data(args, key): + return True + + if not sl_entries: + sl_entries = get_sle_entries_against_voucher(args) + if not sl_entries: + return + + or_conditions = get_conditions_to_validate_future_sle(sl_entries) + + data = frappe.db.sql(""" + select item_code, warehouse, count(name) as total_row + from `tabStock Ledger Entry` + where + ({}) + and timestamp(posting_date, posting_time) + >= timestamp(%(posting_date)s, %(posting_time)s) + and voucher_no != %(voucher_no)s + and is_cancelled = 0 + GROUP BY + item_code, warehouse + """.format(" or ".join(or_conditions)), args, as_dict=1) + + for d in data: + frappe.local.future_sle[key][(d.item_code, d.warehouse)] = d.total_row + + return len(data) + +def validate_future_sle_not_exists(args, key, sl_entries=None): + item_key = '' + if args.get('item_code'): + item_key = (args.get('item_code'), args.get('warehouse')) + + if not sl_entries and hasattr(frappe.local, 'future_sle'): + if (not frappe.local.future_sle.get(key) or + (item_key and item_key not in frappe.local.future_sle.get(key))): + return True + +def get_cached_data(args, key): + if not hasattr(frappe.local, 'future_sle'): + frappe.local.future_sle = {} + + if key not in frappe.local.future_sle: + frappe.local.future_sle[key] = frappe._dict({}) + + if args.get('item_code'): + item_key = (args.get('item_code'), args.get('warehouse')) + count = frappe.local.future_sle[key].get(item_key) + + return True if (count or count == 0) else False + else: + return frappe.local.future_sle[key] + +def get_sle_entries_against_voucher(args): + return frappe.get_all("Stock Ledger Entry", filters={"voucher_type": args.voucher_type, "voucher_no": args.voucher_no}, fields=["item_code", "warehouse"], order_by="creation asc") - if not sl_entries: - return - +def get_conditions_to_validate_future_sle(sl_entries): warehouse_items_map = {} for entry in sl_entries: if entry.warehouse not in warehouse_items_map: @@ -522,23 +639,10 @@ def future_sle_exists(args): or_conditions = [] for warehouse, items in warehouse_items_map.items(): or_conditions.append( - "warehouse = '{}' and item_code in ({})".format( - warehouse, - ", ".join(frappe.db.escape(item) for item in items) - ) - ) + f"""warehouse = {frappe.db.escape(warehouse)} + and item_code in ({', '.join(frappe.db.escape(item) for item in items)})""") - return frappe.db.sql(""" - select name - from `tabStock Ledger Entry` - where - ({}) - and timestamp(posting_date, posting_time) - >= timestamp(%(posting_date)s, %(posting_time)s) - and voucher_no != %(voucher_no)s - and is_cancelled = 0 - limit 1 - """.format(" or ".join(or_conditions)), args) + return or_conditions def create_repost_item_valuation_entry(args): args = frappe._dict(args) diff --git a/erpnext/controllers/subcontracting.py b/erpnext/controllers/subcontracting.py new file mode 100644 index 00000000000..36ae1102164 --- /dev/null +++ b/erpnext/controllers/subcontracting.py @@ -0,0 +1,393 @@ +import frappe +import copy +from frappe import _ +from frappe.utils import flt, cint, get_link_to_form +from collections import defaultdict +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + +class Subcontracting(): + def set_materials_for_subcontracted_items(self, raw_material_table): + if self.doctype == 'Purchase Invoice' and not self.update_stock: + return + + self.raw_material_table = raw_material_table + self.__identify_change_in_item_table() + self.__prepare_supplied_items() + self.__validate_supplied_items() + + def __prepare_supplied_items(self): + self.initialized_fields() + self.__get_purchase_orders() + self.__get_pending_qty_to_receive() + self.get_available_materials() + self.__remove_changed_rows() + self.__set_supplied_items() + + def initialized_fields(self): + self.available_materials = frappe._dict() + self.__transferred_items = frappe._dict() + self.alternative_item_details = frappe._dict() + self.__get_backflush_based_on() + + def __get_backflush_based_on(self): + self.backflush_based_on = frappe.db.get_single_value("Buying Settings", + "backflush_raw_materials_of_subcontract_based_on") + + def __get_purchase_orders(self): + self.purchase_orders = [] + + if self.doctype == 'Purchase Order': + return + + self.purchase_orders = [d.purchase_order for d in self.items if d.purchase_order] + + def __identify_change_in_item_table(self): + self.__changed_name = [] + self.__reference_name = [] + + if self.doctype == 'Purchase Order' or self.is_new(): + self.set(self.raw_material_table, []) + return + + item_dict = self.__get_data_before_save() + if not item_dict: + return True + + for n_row in self.items: + self.__reference_name.append(n_row.name) + if (n_row.name not in item_dict) or (n_row.item_code, n_row.qty) != item_dict[n_row.name]: + self.__changed_name.append(n_row.name) + + if item_dict.get(n_row.name): + del item_dict[n_row.name] + + self.__changed_name.extend(item_dict.keys()) + + def __get_data_before_save(self): + item_dict = {} + if self.doctype in ['Purchase Receipt', 'Purchase Invoice'] and self._doc_before_save: + for row in self._doc_before_save.get('items'): + item_dict[row.name] = (row.item_code, row.qty) + + return item_dict + + def get_available_materials(self): + ''' Get the available raw materials which has been transferred to the supplier. + available_materials = { + (item_code, subcontracted_item, purchase_order): { + 'qty': 1, 'serial_no': [ABC], 'batch_no': {'batch1': 1}, 'data': item_details + } + } + ''' + if not self.purchase_orders: + return + + for row in self.__get_transferred_items(): + key = (row.rm_item_code, row.main_item_code, row.purchase_order) + + if key not in self.available_materials: + self.available_materials.setdefault(key, frappe._dict({'qty': 0, 'serial_no': [], + 'batch_no': defaultdict(float), 'item_details': row, 'po_details': []}) + ) + + details = self.available_materials[key] + details.qty += row.qty + details.po_details.append(row.po_detail) + + if row.serial_no: + details.serial_no.extend(get_serial_nos(row.serial_no)) + + if row.batch_no: + details.batch_no[row.batch_no] += row.qty + + self.__set_alternative_item_details(row) + + self.__transferred_items = copy.deepcopy(self.available_materials) + for doctype in ['Purchase Receipt', 'Purchase Invoice']: + self.__update_consumed_materials(doctype) + + def __update_consumed_materials(self, doctype, return_consumed_items=False): + '''Deduct the consumed materials from the available materials.''' + + pr_items = self.__get_received_items(doctype) + if not pr_items: + return ([], {}) if return_consumed_items else None + + pr_items = {d.name: d.get(self.get('po_field') or 'purchase_order') for d in pr_items} + consumed_materials = self.__get_consumed_items(doctype, pr_items.keys()) + + if return_consumed_items: + return (consumed_materials, pr_items) + + for row in consumed_materials: + key = (row.rm_item_code, row.main_item_code, pr_items.get(row.reference_name)) + if not self.available_materials.get(key): + continue + + self.available_materials[key]['qty'] -= row.consumed_qty + if row.serial_no: + self.available_materials[key]['serial_no'] = list( + set(self.available_materials[key]['serial_no']) - set(get_serial_nos(row.serial_no)) + ) + + if row.batch_no: + self.available_materials[key]['batch_no'][row.batch_no] -= row.consumed_qty + + def __get_transferred_items(self): + fields = ['`tabStock Entry`.`purchase_order`'] + alias_dict = {'item_code': 'rm_item_code', 'subcontracted_item': 'main_item_code', 'basic_rate': 'rate'} + + child_table_fields = ['item_code', 'item_name', 'description', 'qty', 'basic_rate', 'amount', + 'serial_no', 'uom', 'subcontracted_item', 'stock_uom', 'batch_no', 'conversion_factor', + 's_warehouse', 't_warehouse', 'item_group', 'po_detail'] + + if self.backflush_based_on == 'BOM': + child_table_fields.append('original_item') + + for field in child_table_fields: + fields.append(f'`tabStock Entry Detail`.`{field}` As {alias_dict.get(field, field)}') + + filters = [['Stock Entry', 'docstatus', '=', 1], ['Stock Entry', 'purpose', '=', 'Send to Subcontractor'], + ['Stock Entry', 'purchase_order', 'in', self.purchase_orders]] + + return frappe.get_all('Stock Entry', fields = fields, filters=filters) + + def __get_received_items(self, doctype): + fields = [] + self.po_field = 'purchase_order' + + for field in ['name', self.po_field, 'parent']: + fields.append(f'`tab{doctype} Item`.`{field}`') + + filters = [[doctype, 'docstatus', '=', 1], [f'{doctype} Item', self.po_field, 'in', self.purchase_orders]] + if doctype == 'Purchase Invoice': + filters.append(['Purchase Invoice', 'update_stock', "=", 1]) + + return frappe.get_all(f'{doctype}', fields = fields, filters = filters) + + def __get_consumed_items(self, doctype, pr_items): + return frappe.get_all('Purchase Receipt Item Supplied', + fields = ['serial_no', 'rm_item_code', 'reference_name', 'batch_no', 'consumed_qty', 'main_item_code'], + filters = {'docstatus': 1, 'reference_name': ('in', list(pr_items)), 'parenttype': doctype}) + + def __set_alternative_item_details(self, row): + if row.get('original_item'): + self.alternative_item_details[row.get('original_item')] = row + + def __get_pending_qty_to_receive(self): + '''Get qty to be received against the purchase order.''' + + self.qty_to_be_received = defaultdict(float) + + if self.doctype != 'Purchase Order' and self.backflush_based_on != 'BOM' and self.purchase_orders: + for row in frappe.get_all('Purchase Order Item', + fields = ['item_code', '(qty - received_qty) as qty', 'parent', 'name'], + filters = {'docstatus': 1, 'parent': ('in', self.purchase_orders)}): + + self.qty_to_be_received[(row.item_code, row.parent)] += row.qty + + def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): + doctype = 'BOM Item' if not exploded_item else 'BOM Explosion Item' + fields = [f'`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit'] + + alias_dict = {'item_code': 'rm_item_code', 'name': 'bom_detail_no', 'source_warehouse': 'reserve_warehouse'} + for field in ['item_code', 'name', 'rate', 'stock_uom', + 'source_warehouse', 'description', 'item_name', 'stock_uom']: + fields.append(f'`tab{doctype}`.`{field}` As {alias_dict.get(field, field)}') + + filters = [[doctype, 'parent', '=', bom_no], [doctype, 'docstatus', '=', 1], + ['BOM', 'item', '=', item_code], [doctype, 'sourced_by_supplier', '=', 0]] + + return frappe.get_all('BOM', fields = fields, filters=filters, order_by = f'`tab{doctype}`.`idx`') or [] + + def __remove_changed_rows(self): + if not self.__changed_name: + return + + i=1 + self.set(self.raw_material_table, []) + for d in self._doc_before_save.supplied_items: + if d.reference_name in self.__changed_name: + continue + + if (d.reference_name not in self.__reference_name): + continue + + d.idx = i + self.append('supplied_items', d) + + i += 1 + + def __set_supplied_items(self): + self.bom_items = {} + + has_supplied_items = True if self.get(self.raw_material_table) else False + for row in self.items: + if (self.doctype != 'Purchase Order' and ((self.__changed_name and row.name not in self.__changed_name) + or (has_supplied_items and not self.__changed_name))): + continue + + if self.doctype == 'Purchase Order' or self.backflush_based_on == 'BOM': + for bom_item in self.__get_materials_from_bom(row.item_code, row.bom, row.get('include_exploded_items')): + qty = (flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor) + bom_item.main_item_code = row.item_code + self.__update_reserve_warehouse(bom_item, row) + self.__set_alternative_item(bom_item) + self.__add_supplied_item(row, bom_item, qty) + + elif self.backflush_based_on != 'BOM': + for key, transfer_item in self.available_materials.items(): + if (key[1], key[2]) == (row.item_code, row.purchase_order) and transfer_item.qty > 0: + qty = self.__get_qty_based_on_material_transfer(row, transfer_item) or 0 + transfer_item.qty -= qty + self.__add_supplied_item(row, transfer_item.get('item_details'), qty) + + if self.qty_to_be_received: + self.qty_to_be_received[(row.item_code, row.purchase_order)] -= row.qty + + def __update_reserve_warehouse(self, row, item): + if self.doctype == 'Purchase Order': + row.reserve_warehouse = (self.set_reserve_warehouse or item.warehouse) + + def __get_qty_based_on_material_transfer(self, item_row, transfer_item): + key = (item_row.item_code, item_row.purchase_order) + + if self.qty_to_be_received == item_row.qty: + return transfer_item.qty + + if self.qty_to_be_received: + qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key, 0)) + transfer_item.item_details.required_qty = transfer_item.qty + + if (transfer_item.serial_no or frappe.get_cached_value('UOM', + transfer_item.item_details.stock_uom, 'must_be_whole_number')): + return frappe.utils.ceil(qty) + + return qty + + def __set_alternative_item(self, bom_item): + if self.alternative_item_details.get(bom_item.rm_item_code): + bom_item.update(self.alternative_item_details[bom_item.rm_item_code]) + + def __add_supplied_item(self, item_row, bom_item, qty): + bom_item.conversion_factor = item_row.conversion_factor + rm_obj = self.append(self.raw_material_table, bom_item) + rm_obj.reference_name = item_row.name + + if self.doctype == 'Purchase Order': + rm_obj.required_qty = qty + else: + rm_obj.consumed_qty = 0 + rm_obj.purchase_order = item_row.purchase_order + self.__set_batch_nos(bom_item, item_row, rm_obj, qty) + + def __set_batch_nos(self, bom_item, item_row, rm_obj, qty): + key = (rm_obj.rm_item_code, item_row.item_code, item_row.purchase_order) + + if (self.available_materials.get(key) and self.available_materials[key]['batch_no']): + new_rm_obj = None + for batch_no, batch_qty in self.available_materials[key]['batch_no'].items(): + if batch_qty >= qty: + self.__set_batch_no_as_per_qty(item_row, rm_obj, batch_no, qty) + self.available_materials[key]['batch_no'][batch_no] -= qty + return + + elif qty > 0 and batch_qty > 0: + qty -= batch_qty + new_rm_obj = self.append(self.raw_material_table, bom_item) + new_rm_obj.reference_name = item_row.name + self.__set_batch_no_as_per_qty(item_row, new_rm_obj, batch_no, batch_qty) + self.available_materials[key]['batch_no'][batch_no] = 0 + + if abs(qty) > 0 and not new_rm_obj: + self.__set_consumed_qty(rm_obj, qty) + else: + self.__set_consumed_qty(rm_obj, qty, bom_item.required_qty or qty) + self.__set_serial_nos(item_row, rm_obj) + + def __set_consumed_qty(self, rm_obj, consumed_qty, required_qty=0): + rm_obj.required_qty = required_qty + rm_obj.consumed_qty = consumed_qty + + def __set_batch_no_as_per_qty(self, item_row, rm_obj, batch_no, qty): + rm_obj.update({'consumed_qty': qty, 'batch_no': batch_no, + 'required_qty': qty, 'purchase_order': item_row.purchase_order}) + + self.__set_serial_nos(item_row, rm_obj) + + def __set_serial_nos(self, item_row, rm_obj): + key = (rm_obj.rm_item_code, item_row.item_code, item_row.purchase_order) + if (self.available_materials.get(key) and self.available_materials[key]['serial_no']): + used_serial_nos = self.available_materials[key]['serial_no'][0: cint(rm_obj.consumed_qty)] + rm_obj.serial_no = '\n'.join(used_serial_nos) + + # Removed the used serial nos from the list + for sn in used_serial_nos: + self.available_materials[key]['serial_no'].remove(sn) + + def set_consumed_qty_in_po(self): + # Update consumed qty back in the purchase order + if self.is_subcontracted != 'Yes': + return + + self.__get_purchase_orders() + itemwise_consumed_qty = defaultdict(float) + for doctype in ['Purchase Receipt', 'Purchase Invoice']: + consumed_items, pr_items = self.__update_consumed_materials(doctype, return_consumed_items=True) + + for row in consumed_items: + key = (row.rm_item_code, row.main_item_code, pr_items.get(row.reference_name)) + itemwise_consumed_qty[key] += row.consumed_qty + + self.__update_consumed_qty_in_po(itemwise_consumed_qty) + + def __update_consumed_qty_in_po(self, itemwise_consumed_qty): + fields = ['main_item_code', 'rm_item_code', 'parent', 'supplied_qty', 'name'] + filters = {'docstatus': 1, 'parent': ('in', self.purchase_orders)} + + for row in frappe.get_all('Purchase Order Item Supplied', fields = fields, filters=filters, order_by='idx'): + key = (row.rm_item_code, row.main_item_code, row.parent) + consumed_qty = itemwise_consumed_qty.get(key, 0) + + if row.supplied_qty < consumed_qty: + consumed_qty = row.supplied_qty + + itemwise_consumed_qty[key] -= consumed_qty + frappe.db.set_value('Purchase Order Item Supplied', row.name, 'consumed_qty', consumed_qty) + + def __validate_supplied_items(self): + if self.doctype not in ['Purchase Invoice', 'Purchase Receipt']: + return + + for row in self.get(self.raw_material_table): + self.__validate_consumed_qty(row) + + key = (row.rm_item_code, row.main_item_code, row.purchase_order) + if not self.__transferred_items or not self.__transferred_items.get(key): + return + + self.__validate_batch_no(row, key) + self.__validate_serial_no(row, key) + + def __validate_consumed_qty(self, row): + if self.backflush_based_on != 'BOM' and flt(row.consumed_qty) == 0.0: + msg = f'Row {row.idx}: the consumed qty cannot be zero for the item {frappe.bold(row.rm_item_code)}' + + frappe.throw(_(msg),title=_('Consumed Items Qty Check')) + + def __validate_batch_no(self, row, key): + if row.get('batch_no') and row.get('batch_no') not in self.__transferred_items.get(key).get('batch_no'): + link = get_link_to_form('Purchase Order', row.purchase_order) + msg = f'The Batch No {frappe.bold(row.get("batch_no"))} has not supplied against the Purchase Order {link}' + frappe.throw(_(msg), title=_("Incorrect Batch Consumed")) + + def __validate_serial_no(self, row, key): + if row.get('serial_no'): + serial_nos = get_serial_nos(row.get('serial_no')) + incorrect_sn = set(serial_nos).difference(self.__transferred_items.get(key).get('serial_no')) + + if incorrect_sn: + incorrect_sn = "\n".join(incorrect_sn) + link = get_link_to_form('Purchase Order', row.purchase_order) + msg = f'The Serial Nos {incorrect_sn} has not supplied against the Purchase Order {link}' + frappe.throw(_(msg), title=_("Incorrect Serial Number Consumed")) \ No newline at end of file diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index dda642a5323..56da5b71da0 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -54,6 +54,7 @@ class calculate_taxes_and_totals(object): if item.item_code and item.get('item_tax_template'): item_doc = frappe.get_cached_doc("Item", item.item_code) args = { + 'net_rate': item.net_rate or item.rate, 'tax_category': self.doc.get('tax_category'), 'posting_date': self.doc.get('posting_date'), 'bill_date': self.doc.get('bill_date'), @@ -77,10 +78,12 @@ class calculate_taxes_and_totals(object): taxes = _get_item_tax_template(args, item_taxes + item_group_taxes, for_validate=True) - if item.item_tax_template not in taxes: - frappe.throw(_("Row {0}: Invalid Item Tax Template for item {1}").format( - item.idx, frappe.bold(item.item_code) - )) + if taxes: + if item.item_tax_template not in taxes: + item.item_tax_template = taxes[0] + frappe.msgprint(_("Row {0}: Item Tax template updated as per validity and rate applied").format( + item.idx, frappe.bold(item.item_code) + )) def validate_conversion_rate(self): # validate conversion rate @@ -655,7 +658,13 @@ class calculate_taxes_and_totals(object): item.margin_type = None item.margin_rate_or_amount = 0.0 - if item.margin_type and item.margin_rate_or_amount: + if not item.pricing_rules and flt(item.rate) > flt(item.price_list_rate): + item.margin_type = "Amount" + item.margin_rate_or_amount = flt(item.rate - item.price_list_rate, + item.precision("margin_rate_or_amount")) + item.rate_with_margin = item.rate + + elif item.margin_type and item.margin_rate_or_amount: margin_value = item.margin_rate_or_amount if item.margin_type == 'Amount' else flt(item.price_list_rate) * flt(item.margin_rate_or_amount) / 100 rate_with_margin = flt(item.price_list_rate) + flt(margin_value) base_rate_with_margin = flt(rate_with_margin) * flt(self.doc.conversion_rate) @@ -683,7 +692,6 @@ class calculate_taxes_and_totals(object): self.calculate_paid_amount() - def get_itemised_tax_breakup_html(doc): if not doc.taxes: return diff --git a/erpnext/crm/doctype/appointment/appointment.json b/erpnext/crm/doctype/appointment/appointment.json index 8517ddec321..fe7b4e17f0b 100644 --- a/erpnext/crm/doctype/appointment/appointment.json +++ b/erpnext/crm/doctype/appointment/appointment.json @@ -102,7 +102,7 @@ } ], "links": [], - "modified": "2020-01-28 16:16:45.447213", + "modified": "2021-06-30 13:09:14.228756", "modified_by": "Administrator", "module": "CRM", "name": "Appointment", @@ -153,6 +153,18 @@ "role": "Sales User", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Employee", + "share": 1, + "write": 1 } ], "quick_entry": 1, diff --git a/erpnext/crm/doctype/appointment/appointment.py b/erpnext/crm/doctype/appointment/appointment.py index 2009ebf7cba..df73f09c493 100644 --- a/erpnext/crm/doctype/appointment/appointment.py +++ b/erpnext/crm/doctype/appointment/appointment.py @@ -38,7 +38,7 @@ class Appointment(Document): number_of_agents = frappe.db.get_single_value('Appointment Booking Settings', 'number_of_agents') if not number_of_agents == 0: if (number_of_appointments_in_same_slot >= number_of_agents): - frappe.throw('Time slot is not available') + frappe.throw(_('Time slot is not available')) # Link lead if not self.party: lead = self.find_lead_by_email() @@ -75,10 +75,10 @@ class Appointment(Document): subject=_('Appointment Confirmation')) if frappe.session.user == "Guest": frappe.msgprint( - 'Please check your email to confirm the appointment') + _('Please check your email to confirm the appointment')) else : frappe.msgprint( - 'Appointment was created. But no lead was found. Please check the email to confirm') + _('Appointment was created. But no lead was found. Please check the email to confirm')) def on_change(self): # Sync Calendar @@ -91,7 +91,7 @@ class Appointment(Document): def set_verified(self, email): if not email == self.customer_email: - frappe.throw('Email verification failed.') + frappe.throw(_('Email verification failed.')) # Create new lead self.create_lead_and_link() # Remove unverified status @@ -184,7 +184,7 @@ class Appointment(Document): appointment_event.insert(ignore_permissions=True) self.calendar_event = appointment_event.name self.save(ignore_permissions=True) - + def _get_verify_url(self): verify_route = '/book_appointment/verify' params = { diff --git a/erpnext/crm/doctype/campaign/__init__.py b/erpnext/crm/doctype/campaign/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/crm/doctype/campaign/campaign.js b/erpnext/crm/doctype/campaign/campaign.js new file mode 100644 index 00000000000..11bfa74b29c --- /dev/null +++ b/erpnext/crm/doctype/campaign/campaign.js @@ -0,0 +1,17 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Campaign', { + refresh: function(frm) { + erpnext.toggle_naming_series(); + + if (frm.doc.__islocal) { + frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series"); + } else { + cur_frm.add_custom_button(__("View Leads"), function() { + frappe.route_options = {"source": "Campaign", "campaign_name": frm.doc.name}; + frappe.set_route("List", "Lead"); + }, "fa fa-list", true); + } + } +}); diff --git a/erpnext/selling/doctype/campaign/campaign.json b/erpnext/crm/doctype/campaign/campaign.json similarity index 95% rename from erpnext/selling/doctype/campaign/campaign.json rename to erpnext/crm/doctype/campaign/campaign.json index 986ac1306cd..f833f4c9d11 100644 --- a/erpnext/selling/doctype/campaign/campaign.json +++ b/erpnext/crm/doctype/campaign/campaign.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", @@ -39,17 +40,9 @@ "set_only_once": 1 }, { - "fieldname": "description", - "fieldtype": "Text", - "in_list_view": 1, - "label": "Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "width": "300px" - }, - { - "fieldname": "description_section", - "fieldtype": "Section Break" + "fieldname": "campaign_schedules_section", + "fieldtype": "Section Break", + "label": "Campaign Schedules" }, { "fieldname": "campaign_schedules", @@ -58,16 +51,25 @@ "options": "Campaign Email Schedule" }, { - "fieldname": "campaign_schedules_section", - "fieldtype": "Section Break", - "label": "Campaign Schedules" + "fieldname": "description_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "width": "300px" } ], "icon": "fa fa-bullhorn", "idx": 1, - "modified": "2019-07-22 12:03:39.832342", + "links": [], + "modified": "2021-06-30 18:05:06.412712", "modified_by": "Administrator", - "module": "Selling", + "module": "CRM", "name": "Campaign", "owner": "Administrator", "permissions": [ diff --git a/erpnext/selling/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py similarity index 66% rename from erpnext/selling/doctype/campaign/campaign.py rename to erpnext/crm/doctype/campaign/campaign.py index 10945428aee..e32799f34eb 100644 --- a/erpnext/selling/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -1,9 +1,7 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt -from __future__ import unicode_literals import frappe - from frappe.model.document import Document from frappe.model.naming import set_name_by_naming_series diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py new file mode 100644 index 00000000000..7124b8c7d60 --- /dev/null +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +import unittest + +class TestCampaign(unittest.TestCase): + pass diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index b18129b14fc..ce36069052e 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -78,8 +78,25 @@ class Lead(SellingController): def on_trash(self): frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) + self.unlink_dynamic_links() self.delete_events() + def unlink_dynamic_links(self): + links = frappe.get_all('Dynamic Link', filters={'link_doctype': self.doctype, 'link_name': self.name}, fields=['parent', 'parenttype']) + + for link in links: + linked_doc = frappe.get_doc(link['parenttype'], link['parent']) + + if len(linked_doc.get('links')) == 1: + linked_doc.delete() + else: + to_remove = None + for d in linked_doc.get('links'): + if d.link_doctype == self.doctype and d.link_name == self.name: + to_remove = d + if to_remove: + link_doctype.remove(to_remove) + def has_customer(self): return frappe.db.get_value("Customer", {"lead_name": self.name}) diff --git a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js index 3f5c95ab0aa..fe5707af296 100644 --- a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js +++ b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js @@ -22,10 +22,10 @@ frappe.query_reports["First Response Time for Opportunity"] = { get_chart_data: function (_columns, result) { return { data: { - labels: result.map(d => d[0]), + labels: result.map(d => d.creation_date), datasets: [{ name: "First Response Time", - values: result.map(d => d[1]) + values: result.map(d => d.first_response_time) }] }, type: "line", @@ -35,8 +35,7 @@ frappe.query_reports["First Response Time for Opportunity"] = { hide_days: 0, hide_seconds: 0 }; - value = frappe.utils.get_formatted_duration(d, duration_options); - return value; + return frappe.utils.get_formatted_duration(d, duration_options); } } } 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 6a0dcf460a7..0f2ea96a583 100644 --- a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py +++ b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py @@ -75,7 +75,7 @@ class CourseSchedulingTool(Document): """Validates if Course Start Date is greater than Course End Date""" if self.course_start_date > self.course_end_date: frappe.throw( - "Course Start Date cannot be greater than Course End Date.") + _("Course Start Date cannot be greater than Course End Date.")) def delete_course_schedule(self, rescheduled, reschedule_errors): """Delete all course schedule within the Date range and specified filters""" diff --git a/erpnext/education/utils.py b/erpnext/education/utils.py index 819106bde78..3070e6a3e8a 100644 --- a/erpnext/education/utils.py +++ b/erpnext/education/utils.py @@ -236,7 +236,7 @@ def get_quiz(quiz_name, course): 'questions': questions, 'activity': None, 'is_time_bound': quiz.is_time_bound, - 'duration':quiz.duration + 'duration': quiz.duration } student = get_current_student() @@ -355,11 +355,11 @@ def get_or_create_course_enrollment(course, program): student = get_current_student() course_enrollment = get_enrollment("course", course, student.name) if not course_enrollment: - program_enrollment = get_enrollment('program', program, student.name) + program_enrollment = get_enrollment('program', program.name, student.name) if not program_enrollment: frappe.throw(_("You are not enrolled in program {0}").format(program)) return - return student.enroll_in_course(course_name=course, program_enrollment=get_enrollment('program', program, student.name)) + return student.enroll_in_course(course_name=course, program_enrollment=get_enrollment('program', program.name, student.name)) else: return frappe.get_doc('Course Enrollment', course_enrollment) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 3c2e59ab821..b0e662d3f32 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -7,16 +7,21 @@ import frappe import unittest from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice +from erpnext.erpnext_integrations.utils import create_mode_of_payment class TestMpesaSettings(unittest.TestCase): + def setUp(self): + # create payment gateway in setup + create_mpesa_settings(payment_gateway_name="_Test") + create_mpesa_settings(payment_gateway_name="_Account Balance") + create_mpesa_settings(payment_gateway_name="Payment") + def tearDown(self): frappe.db.sql('delete from `tabMpesa Settings`') frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"') def test_creation_of_payment_gateway(self): - create_mpesa_settings(payment_gateway_name="_Test") - - mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") + mode_of_payment = create_mode_of_payment('Mpesa-_Test', payment_type="Phone") self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) self.assertTrue(mode_of_payment.name) self.assertEqual(mode_of_payment.type, "Phone") @@ -47,7 +52,6 @@ class TestMpesaSettings(unittest.TestCase): integration_request.delete() def test_processing_of_callback_payload(self): - create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") @@ -90,7 +94,6 @@ class TestMpesaSettings(unittest.TestCase): pos_invoice.delete() def test_processing_of_multiple_callback_payload(self): - create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") @@ -141,7 +144,6 @@ class TestMpesaSettings(unittest.TestCase): pos_invoice.delete() def test_processing_of_only_one_succes_callback_payload(self): - create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") @@ -202,6 +204,7 @@ def create_mpesa_settings(payment_gateway_name="Express"): doc = frappe.get_doc(dict( #nosec doctype="Mpesa Settings", + sandbox=1, payment_gateway_name=payment_gateway_name, consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", consumer_secret="VI1oS3oBGPJfh3JyvLHw", diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index 5f990cdd034..42d4b9b2b43 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -99,5 +99,7 @@ class PlaidConnector(): response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, offset=len(transactions)) transactions.extend(response["transactions"]) return transactions + except ItemError as e: + raise e except Exception: frappe.log_error(frappe.get_traceback(), _("Plaid transactions sync error")) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index bbc2ca8846c..37bf2824505 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -16,6 +16,10 @@ frappe.ui.form.on('Plaid Settings', { new erpnext.integrations.plaidLink(frm); }); + frm.add_custom_button(__('Reset Plaid Link'), () => { + new erpnext.integrations.plaidLink(frm); + }); + frm.add_custom_button(__("Sync Now"), () => { frappe.call({ method: "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.enqueue_synchronization", diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index ce15e47c5ef..3ef069b5e20 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -12,6 +12,7 @@ from frappe.desk.doctype.tag.tag import add_tag from frappe.model.document import Document from frappe.utils import add_months, formatdate, getdate, today +from plaid.errors import ItemError class PlaidSettings(Document): @staticmethod @@ -51,7 +52,7 @@ def add_institution(token, response): }) bank.insert() except Exception: - frappe.throw(frappe.get_traceback()) + frappe.log_error(frappe.get_traceback(), title=_('Plaid Link Error')) else: bank = frappe.get_doc("Bank", response["institution"]["name"]) bank.plaid_access_token = access_token @@ -83,7 +84,12 @@ def add_bank_accounts(response, bank, company): if not acc_subtype: add_account_subtype(account["subtype"]) - if not frappe.db.exists("Bank Account", dict(integration_id=account["id"])): + existing_bank_account = frappe.db.exists("Bank Account", { + 'account_name': account["name"], + 'bank': bank["bank_name"] + }) + + if not existing_bank_account: try: new_account = frappe.get_doc({ "doctype": "Bank Account", @@ -103,10 +109,27 @@ def add_bank_accounts(response, bank, company): except frappe.UniqueValidationError: frappe.msgprint(_("Bank account {0} already exists and could not be created again").format(account["name"])) except Exception: - frappe.throw(frappe.get_traceback()) + frappe.log_error(frappe.get_traceback(), title=_("Plaid Link Error")) + frappe.throw(_("There was an error creating Bank Account while linking with Plaid."), + title=_("Plaid Link Failed")) else: - result.append(frappe.db.get_value("Bank Account", dict(integration_id=account["id"]), "name")) + try: + existing_account = frappe.get_doc('Bank Account', existing_bank_account) + existing_account.update({ + "bank": bank["bank_name"], + "account_name": account["name"], + "account_type": account.get("type", ""), + "account_subtype": account.get("subtype", ""), + "mask": account.get("mask", ""), + "integration_id": account["id"] + }) + existing_account.save() + result.append(existing_bank_account) + except Exception: + frappe.log_error(frappe.get_traceback(), title=_("Plaid Link Error")) + frappe.throw(_("There was an error updating Bank Account {} while linking with Plaid.").format( + existing_bank_account), title=_("Plaid Link Failed")) return result @@ -172,9 +195,16 @@ def get_transactions(bank, bank_account=None, start_date=None, end_date=None): account_id = None plaid = PlaidConnector(access_token) - transactions = plaid.get_transactions(start_date=start_date, end_date=end_date, account_id=account_id) - return transactions + try: + transactions = plaid.get_transactions(start_date=start_date, end_date=end_date, account_id=account_id) + except ItemError as e: + if e.code == "ITEM_LOGIN_REQUIRED": + msg = _("There was an error syncing transactions.") + " " + msg += _("Please refresh or reset the Plaid linking of the Bank {}.").format(bank) + " " + frappe.log_error(msg, title=_("Plaid Link Refresh Required")) + + return transactions or [] def new_bank_transaction(transaction): diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 3840e781b4c..a5e162f8b5d 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -52,7 +52,8 @@ def create_mode_of_payment(gateway, payment_type="General"): "payment_gateway": gateway }, ['payment_account']) - if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account: + mode_of_payment = frappe.db.exists("Mode of Payment", gateway) + if not mode_of_payment and payment_gateway_account: mode_of_payment = frappe.get_doc({ "doctype": "Mode of Payment", "mode_of_payment": gateway, @@ -66,6 +67,10 @@ def create_mode_of_payment(gateway, payment_type="General"): }) mode_of_payment.insert(ignore_permissions=True) + return mode_of_payment + elif mode_of_payment: + return frappe.get_doc("Mode of Payment", mode_of_payment) + def get_tracking_url(carrier, tracking_number): # Return the formatted Tracking URL. tracking_url = '' diff --git a/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py b/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py index c93b788aed7..33119d8185f 100644 --- a/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py +++ b/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe import unittest import json -from frappe.utils import getdate +from frappe.utils import getdate, strip_html from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_patient class TestPatientHistorySettings(unittest.TestCase): @@ -44,9 +44,9 @@ class TestPatientHistorySettings(unittest.TestCase): self.assertTrue(medical_rec) medical_rec = frappe.get_doc("Patient Medical Record", medical_rec) - expected_subject = "Date: {0}
Rating: 3
Feedback: Test Patient History Settings
".format( + expected_subject = "Date: {0}Rating: 3Feedback: Test Patient History Settings".format( frappe.utils.format_date(getdate())) - self.assertEqual(medical_rec.subject, expected_subject) + self.assertEqual(strip_html(medical_rec.subject), expected_subject) self.assertEqual(medical_rec.patient, patient) self.assertEqual(medical_rec.communication_date, getdate()) @@ -101,4 +101,4 @@ def create_doc(patient): }).insert() doc.submit() - return doc \ No newline at end of file + return doc diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 3da606b68b8..59b011d1a97 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -25,7 +25,8 @@ doctype_js = { "Address": "public/js/address.js", "Communication": "public/js/communication.js", "Event": "public/js/event.js", - "Newsletter": "public/js/newsletter.js" + "Newsletter": "public/js/newsletter.js", + "Contact": "public/js/contact.js" } override_doctype_class = { @@ -158,6 +159,7 @@ website_route_rules = [ "parents": [{"label": _("Material Request"), "route": "material-requests"}] } }, + {"from_route": "/project", "to_route": "Project"} ] standard_portal_menu_items = [ @@ -244,7 +246,10 @@ doc_events = { "erpnext.portal.utils.set_default_role"] }, "Communication": { - "on_update": "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time" + "on_update": [ + "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time", + "erpnext.support.doctype.issue.issue.set_first_response_time" + ] }, ("Sales Taxes and Charges Template", 'Price List'): { "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings" diff --git a/erpnext/hr/doctype/attendance/attendance.js b/erpnext/hr/doctype/attendance/attendance.js index c3c3cb82f94..7964078c7f0 100644 --- a/erpnext/hr/doctype/attendance/attendance.js +++ b/erpnext/hr/doctype/attendance/attendance.js @@ -11,5 +11,5 @@ cur_frm.cscript.onload = function(doc, cdt, cdn) { cur_frm.fields_dict.employee.get_query = function(doc,cdt,cdn) { return{ query: "erpnext.controllers.queries.employee_query" - } + } } diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index f3b8a799b3c..3412675d811 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -15,6 +15,7 @@ class Attendance(Document): validate_status(self.status, ["Present", "Absent", "On Leave", "Half Day", "Work From Home"]) self.validate_attendance_date() self.validate_duplicate_record() + self.validate_employee_status() self.check_leave_record() def validate_attendance_date(self): @@ -38,6 +39,10 @@ class Attendance(Document): frappe.throw(_("Attendance for employee {0} is already marked for the date {1}").format( frappe.bold(self.employee), frappe.bold(self.attendance_date))) + def validate_employee_status(self): + if frappe.db.get_value("Employee", self.employee, "status") == "Inactive": + frappe.throw(_("Cannot mark attendance for an Inactive employee {0}").format(self.employee)) + def check_leave_record(self): leave_record = frappe.db.sql(""" select leave_type, half_day, half_day_date diff --git a/erpnext/hr/doctype/attendance/attendance_list.js b/erpnext/hr/doctype/attendance/attendance_list.js index 0c7eafe9c61..9a3bac0eb23 100644 --- a/erpnext/hr/doctype/attendance/attendance_list.js +++ b/erpnext/hr/doctype/attendance/attendance_list.js @@ -21,6 +21,9 @@ frappe.listview_settings['Attendance'] = { label: __('For Employee'), fieldtype: 'Link', options: 'Employee', + get_query: () => { + return {query: "erpnext.controllers.queries.employee_query"} + }, reqd: 1, onchange: function() { dialog.set_df_property("unmarked_days", "hidden", 1); diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json index 5442ed56c31..d592a9c79e2 100644 --- a/erpnext/hr/doctype/employee/employee.json +++ b/erpnext/hr/doctype/employee/employee.json @@ -207,7 +207,7 @@ "label": "Status", "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Active\nInactive\nLeft", + "options": "Active\nInactive\nSuspended\nLeft", "reqd": 1, "search_index": 1 }, @@ -813,7 +813,7 @@ "idx": 24, "image_field": "image", "links": [], - "modified": "2021-06-12 11:31:37.730760", + "modified": "2021-06-17 11:31:37.730760", "modified_by": "Administrator", "module": "HR", "name": "Employee", diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index bc5694226ad..fa017d9d4c2 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe -from frappe.utils import getdate, validate_email_address, today, add_years, format_datetime, cstr +from frappe.utils import getdate, validate_email_address, today, add_years, cstr from frappe.model.naming import set_name_by_naming_series from frappe import throw, _, scrub from frappe.permissions import add_user_permission, remove_user_permission, \ @@ -12,7 +12,6 @@ from frappe.permissions import add_user_permission, remove_user_permission, \ from frappe.model.document import Document from erpnext.utilities.transaction_base import delete_events from frappe.utils.nestedset import NestedSet -from erpnext.hr.doctype.job_offer.job_offer import get_staffing_plan_detail class EmployeeUserDisabledError(frappe.ValidationError): pass class EmployeeLeftValidationError(frappe.ValidationError): pass @@ -37,7 +36,7 @@ class Employee(NestedSet): def validate(self): from erpnext.controllers.status_updater import validate_status - validate_status(self.status, ["Active", "Inactive", "Left"]) + validate_status(self.status, ["Active", "Inactive", "Suspended", "Left"]) self.employee = self.name self.set_employee_name() diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py index 285374d9f69..e853bee69f2 100644 --- a/erpnext/hr/doctype/employee/employee_dashboard.py +++ b/erpnext/hr/doctype/employee/employee_dashboard.py @@ -7,7 +7,8 @@ def get_data(): 'heatmap_message': _('This is based on the attendance of this Employee'), 'fieldname': 'employee', 'non_standard_fieldnames': { - 'Bank Account': 'party' + 'Bank Account': 'party', + 'Employee Grievance': 'raised_by' }, 'transactions': [ { @@ -20,7 +21,7 @@ def get_data(): }, { 'label': _('Lifecycle'), - 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Separation'] + 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Separation', 'Employee Grievance'] }, { 'label': _('Shift'), diff --git a/erpnext/hr/doctype/employee/employee_list.js b/erpnext/hr/doctype/employee/employee_list.js index 6679e318c24..d37e1496ca3 100644 --- a/erpnext/hr/doctype/employee/employee_list.js +++ b/erpnext/hr/doctype/employee/employee_list.js @@ -3,7 +3,7 @@ frappe.listview_settings['Employee'] = { filters: [["status","=", "Active"]], get_indicator: function(doc) { var indicator = [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status]; - indicator[1] = {"Active": "green", "Inactive": "red", "Left": "gray"}[doc.status]; + indicator[1] = {"Active": "green", "Inactive": "red", "Left": "gray", "Suspended": "orange"}[doc.status]; return indicator; } }; diff --git a/erpnext/hr/doctype/employee_grievance/__init__.py b/erpnext/hr/doctype/employee_grievance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance.js b/erpnext/hr/doctype/employee_grievance/employee_grievance.js new file mode 100644 index 00000000000..25c5badbc77 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance.js @@ -0,0 +1,39 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Employee Grievance', { + setup: function(frm) { + frm.set_query('grievance_against_party', function() { + return { + filters: { + name: ['in', [ + 'Company', 'Department', 'Employee Group', 'Employee Grade', 'Employee'] + ] + } + }; + }); + frm.set_query('associated_document_type', function() { + let ignore_modules = ["Setup", "Core", "Integrations", "Automation", "Website", + "Utilities", "Event Streaming", "Social", "Chat", "Data Migration", "Printing", "Desk", "Custom"]; + return { + filters: { + istable: 0, + issingle: 0, + module: ["Not In", ignore_modules] + } + }; + }); + }, + + grievance_against_party: function(frm) { + let filters = {}; + if (frm.doc.grievance_against_party == 'Employee' && frm.doc.raised_by) { + filters.name = ["!=", frm.doc.raised_by]; + } + frm.set_query('grievance_against', function() { + return { + filters: filters + }; + }); + }, +}); diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance.json b/erpnext/hr/doctype/employee_grievance/employee_grievance.json new file mode 100644 index 00000000000..5a918562afb --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance.json @@ -0,0 +1,261 @@ +{ + "actions": [], + "autoname": "HR-GRIEV-.YYYY.-.#####", + "creation": "2021-05-11 13:41:51.485295", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "subject", + "raised_by", + "employee_name", + "designation", + "column_break_3", + "date", + "status", + "reports_to", + "grievance_details_section", + "grievance_against_party", + "grievance_against", + "grievance_type", + "column_break_11", + "associated_document_type", + "associated_document", + "section_break_14", + "description", + "investigation_details_section", + "cause_of_grievance", + "resolution_details_section", + "resolved_by", + "resolution_date", + "employee_responsible", + "column_break_16", + "resolution_detail", + "amended_from" + ], + "fields": [ + { + "fieldname": "grievance_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Grievance Type", + "options": "Grievance Type", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date ", + "reqd": 1 + }, + { + "default": "Open", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Open\nInvestigated\nResolved\nInvalid", + "reqd": 1 + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Description", + "reqd": 1 + }, + { + "fieldname": "cause_of_grievance", + "fieldtype": "Text", + "label": "Cause of Grievance", + "mandatory_depends_on": "eval: doc.status == \"Investigated\" || doc.status == \"Resolved\"" + }, + { + "fieldname": "resolution_details_section", + "fieldtype": "Section Break", + "label": "Resolution Details" + }, + { + "fieldname": "resolved_by", + "fieldtype": "Link", + "label": "Resolved By", + "mandatory_depends_on": "eval: doc.status == \"Resolved\"", + "options": "User" + }, + { + "fieldname": "employee_responsible", + "fieldtype": "Link", + "label": "Employee Responsible ", + "options": "Employee" + }, + { + "fieldname": "resolution_detail", + "fieldtype": "Small Text", + "label": "Resolution Details", + "mandatory_depends_on": "eval: doc.status == \"Resolved\"" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "resolution_date", + "fieldtype": "Date", + "label": "Resolution Date", + "mandatory_depends_on": "eval: doc.status == \"Resolved\"" + }, + { + "fieldname": "grievance_against", + "fieldtype": "Dynamic Link", + "label": "Grievance Against", + "options": "grievance_against_party", + "reqd": 1 + }, + { + "fieldname": "raised_by", + "fieldtype": "Link", + "label": "Raised By", + "options": "Employee", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Employee Grievance", + "print_hide": 1, + "read_only": 1 + }, + { + "fetch_from": "raised_by.designation", + "fieldname": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation", + "read_only": 1 + }, + { + "fetch_from": "raised_by.reports_to", + "fieldname": "reports_to", + "fieldtype": "Link", + "label": "Reports To", + "options": "Employee", + "read_only": 1 + }, + { + "fieldname": "grievance_details_section", + "fieldtype": "Section Break", + "label": "Grievance Details" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_14", + "fieldtype": "Section Break" + }, + { + "fieldname": "grievance_against_party", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Grievance Against Party", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "associated_document_type", + "fieldtype": "Link", + "label": "Associated Document Type", + "options": "DocType" + }, + { + "fieldname": "associated_document", + "fieldtype": "Dynamic Link", + "label": "Associated Document", + "options": "associated_document_type" + }, + { + "fieldname": "investigation_details_section", + "fieldtype": "Section Break", + "label": "Investigation Details" + }, + { + "fetch_from": "raised_by.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee Name", + "read_only": 1 + }, + { + "fieldname": "subject", + "fieldtype": "Data", + "label": "Subject", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-06-21 12:51:01.499486", + "modified_by": "Administrator", + "module": "HR", + "name": "Employee Grievance", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "search_fields": "subject,raised_by,grievance_against_party", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "subject", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance.py b/erpnext/hr/doctype/employee_grievance/employee_grievance.py new file mode 100644 index 00000000000..503b5ea4449 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance.py @@ -0,0 +1,15 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, bold +from frappe.model.document import Document + +class EmployeeGrievance(Document): + def on_submit(self): + if self.status not in ["Invalid", "Resolved"]: + frappe.throw(_("Only Employee Grievance with status {0} or {1} can be submitted").format( + bold("Invalid"), + bold("Resolved")) + ) + diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance_list.js b/erpnext/hr/doctype/employee_grievance/employee_grievance_list.js new file mode 100644 index 00000000000..fc08e216099 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance_list.js @@ -0,0 +1,12 @@ +frappe.listview_settings["Employee Grievance"] = { + has_indicator_for_draft: 1, + get_indicator: function(doc) { + var colors = { + "Open": "red", + "Investigated": "orange", + "Resolved": "green", + "Invalid": "grey" + }; + return [__(doc.status), colors[doc.status], "status,=," + doc.status]; + } +}; \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_grievance/test_employee_grievance.py b/erpnext/hr/doctype/employee_grievance/test_employee_grievance.py new file mode 100644 index 00000000000..a615b20a5a2 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/test_employee_grievance.py @@ -0,0 +1,51 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +import unittest +from frappe.utils import today +from erpnext.hr.doctype.employee.test_employee import make_employee +class TestEmployeeGrievance(unittest.TestCase): + def test_create_employee_grievance(self): + create_employee_grievance() + +def create_employee_grievance(): + grievance_type = create_grievance_type() + emp_1 = make_employee("test_emp_grievance_@example.com", company="_Test Company") + emp_2 = make_employee("testculprit@example.com", company="_Test Company") + + grievance = frappe.new_doc("Employee Grievance") + grievance.subject = "Test Employee Grievance" + grievance.raised_by = emp_1 + grievance.date = today() + grievance.grievance_type = grievance_type + grievance.grievance_against_party = "Employee" + grievance.grievance_against = emp_2 + grievance.description = "test descrip" + + #set cause + grievance.cause_of_grievance = "test cause" + + #resolution details + grievance.resolution_date = today() + grievance.resolution_detail = "test resolution detail" + grievance.resolved_by = "test_emp_grievance_@example.com" + grievance.employee_responsible = emp_2 + grievance.status = "Resolved" + + grievance.save() + grievance.submit() + + return grievance + + +def create_grievance_type(): + if frappe.db.exists("Grievance Type", "Employee Abuse"): + return frappe.get_doc("Grievance Type", "Employee Abuse") + grievance_type = frappe.new_doc("Grievance Type") + grievance_type.name = "Employee Abuse" + grievance_type.description = "Test" + grievance_type.save() + + return grievance_type.name + diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index 578eccf787d..96ea686706c 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -72,7 +72,8 @@ class TestExpenseClaim(unittest.TestCase): def test_expense_claim_gl_entry(self): payable_account = get_payable_account(company_name) taxes = generate_taxes() - expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", do_not_submit=True, taxes=taxes) + expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", + do_not_submit=True, taxes=taxes) expense_claim.submit() gl_entries = frappe.db.sql("""select account, debit, credit @@ -82,7 +83,7 @@ class TestExpenseClaim(unittest.TestCase): self.assertTrue(gl_entries) expected_values = dict((d[0], d) for d in [ - ['CGST - _TC4',18.0, 0.0], + ['Output Tax CGST - _TC4',18.0, 0.0], [payable_account, 0.0, 218.0], ["Travel Expenses - _TC4", 200.0, 0.0] ]) @@ -145,7 +146,7 @@ def generate_taxes(): parent_account = frappe.db.get_value('Account', {'company': company_name, 'is_group':1, 'account_type': 'Tax'}, 'name') - account = create_account(company=company_name, account_name="CGST", account_type="Tax", parent_account=parent_account) + account = create_account(company=company_name, account_name="Output Tax CGST", account_type="Tax", parent_account=parent_account) return {'taxes':[{ "account_head": account, "rate": 0, diff --git a/erpnext/hr/doctype/grievance_type/__init__.py b/erpnext/hr/doctype/grievance_type/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/hr/doctype/grievance_type/grievance_type.js b/erpnext/hr/doctype/grievance_type/grievance_type.js new file mode 100644 index 00000000000..425f2fd5b5e --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/grievance_type.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Grievance Type', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/hr/doctype/grievance_type/grievance_type.json b/erpnext/hr/doctype/grievance_type/grievance_type.json new file mode 100644 index 00000000000..1dce00a0e22 --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/grievance_type.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2021-05-11 12:41:50.256071", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "section_break_5", + "description" + ], + "fields": [ + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-06-21 12:54:37.764712", + "modified_by": "Administrator", + "module": "HR", + "name": "Grievance Type", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/hr/doctype/grievance_type/grievance_type.py b/erpnext/hr/doctype/grievance_type/grievance_type.py new file mode 100644 index 00000000000..618cf0a0318 --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/grievance_type.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class GrievanceType(Document): + pass diff --git a/erpnext/hr/doctype/grievance_type/test_grievance_type.py b/erpnext/hr/doctype/grievance_type/test_grievance_type.py new file mode 100644 index 00000000000..a02a34d41fa --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/test_grievance_type.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +import unittest + +class TestGrievanceType(unittest.TestCase): + pass diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_list.js b/erpnext/hr/doctype/job_applicant/job_applicant_list.js index 3b9141ba79c..2ad0d591d8c 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant_list.js +++ b/erpnext/hr/doctype/job_applicant/job_applicant_list.js @@ -2,7 +2,7 @@ // MIT License. See license.txt frappe.listview_settings['Job Applicant'] = { - add_fields: ["company", "designation", "job_applicant", "status"], + add_fields: ["status"], get_indicator: function (doc) { if (doc.status == "Accepted") { return [__(doc.status), "green", "status,=," + doc.status]; diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json index ae02c512c23..3a6539ece9e 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json @@ -110,6 +110,7 @@ "label": "Allocation" }, { + "allow_on_submit": 1, "bold": 1, "fieldname": "new_leaves_allocated", "fieldtype": "Float", @@ -235,7 +236,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-14 15:28:26.335104", + "modified": "2021-06-03 15:28:26.335104", "modified_by": "Administrator", "module": "HR", "name": "Leave Allocation", @@ -277,4 +278,4 @@ "sort_field": "modified", "sort_order": "DESC", "timeline_field": "employee" -} \ 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 11302cad758..4757cd3b192 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py @@ -8,6 +8,7 @@ from frappe import _ from frappe.model.document import Document from erpnext.hr.utils import set_employee_name, get_leave_period from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import expire_allocation, create_leave_ledger_entry +from erpnext.hr.doctype.leave_application.leave_application import get_approved_leaves_for_period class OverlapError(frappe.ValidationError): pass class BackDatedAllocationError(frappe.ValidationError): pass @@ -55,6 +56,43 @@ class LeaveAllocation(Document): if self.carry_forward: self.set_carry_forwarded_leaves_in_previous_allocation(on_cancel=True) + def on_update_after_submit(self): + if self.has_value_changed("new_leaves_allocated"): + self.validate_against_leave_applications() + leaves_to_be_added = self.new_leaves_allocated - self.get_existing_leave_count() + args = { + "leaves": leaves_to_be_added, + "from_date": self.from_date, + "to_date": self.to_date, + "is_carry_forward": 0 + } + create_leave_ledger_entry(self, args, True) + + def get_existing_leave_count(self): + ledger_entries = frappe.get_all("Leave Ledger Entry", + filters={ + "transaction_type": "Leave Allocation", + "transaction_name": self.name, + "employee": self.employee, + "company": self.company, + "leave_type": self.leave_type + }, + pluck="leaves") + total_existing_leaves = 0 + for entry in ledger_entries: + total_existing_leaves += entry + + return total_existing_leaves + + def validate_against_leave_applications(self): + leaves_taken = get_approved_leaves_for_period(self.employee, self.leave_type, + self.from_date, self.to_date) + if flt(leaves_taken) > flt(self.total_leaves_allocated): + if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"): + frappe.msgprint(_("Note: Total allocated leaves {0} shouldn't be less than already approved leaves {1} for the period").format(self.total_leaves_allocated, leaves_taken)) + else: + frappe.throw(_("Total allocated leaves {0} cannot be less than already approved leaves {1} for the period").format(self.total_leaves_allocated, leaves_taken), LessAllocationError) + def update_leave_policy_assignments_when_no_allocations_left(self): allocations = frappe.db.get_list("Leave Allocation", filters = { "docstatus": 1, @@ -225,4 +263,4 @@ def get_unused_leaves(employee, leave_type, from_date, to_date): def validate_carry_forward(leave_type): if not frappe.db.get_value("Leave Type", leave_type, "is_carry_forward"): - frappe.throw(_("Leave Type {0} cannot be carry-forwarded").format(leave_type)) \ No newline at end of file + frappe.throw(_("Leave Type {0} cannot be carry-forwarded").format(leave_type)) diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py index 6e7ae87d08c..fdcd533660b 100644 --- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py @@ -1,10 +1,10 @@ from __future__ import unicode_literals import frappe +import erpnext import unittest from frappe.utils import nowdate, add_months, getdate, add_days from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation, expire_allocation - class TestLeaveAllocation(unittest.TestCase): @classmethod def setUpClass(cls): @@ -164,6 +164,51 @@ class TestLeaveAllocation(unittest.TestCase): leave_allocation.cancel() self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name})) + def test_leave_addition_after_submit(self): + frappe.db.sql("delete from `tabLeave Allocation`") + frappe.db.sql("delete from `tabLeave Ledger Entry`") + + leave_allocation = create_leave_allocation() + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 15) + leave_allocation.new_leaves_allocated = 40 + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 40) + + def test_leave_subtraction_after_submit(self): + frappe.db.sql("delete from `tabLeave Allocation`") + frappe.db.sql("delete from `tabLeave Ledger Entry`") + leave_allocation = create_leave_allocation() + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 15) + leave_allocation.new_leaves_allocated = 10 + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 10) + + def test_against_leave_application_validation_after_submit(self): + frappe.db.sql("delete from `tabLeave Allocation`") + frappe.db.sql("delete from `tabLeave Ledger Entry`") + + leave_allocation = create_leave_allocation() + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 15) + employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + leave_application = frappe.get_doc({ + "doctype": 'Leave Application', + "employee": employee.name, + "leave_type": "_Test Leave Type", + "from_date": add_months(nowdate(), 2), + "to_date": add_months(add_days(nowdate(), 10), 2), + "company": erpnext.get_default_company() or "_Test Company", + "docstatus": 1, + "status": "Approved", + "leave_approver": 'test@example.com' + }) + leave_application.submit() + leave_allocation.new_leaves_allocated = 8 + leave_allocation.total_leaves_allocated = 8 + self.assertRaises(frappe.ValidationError, leave_allocation.submit) + def create_leave_allocation(**args): args = frappe._dict(args) diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index 0bf551e178c..cee6f374fdc 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -4,8 +4,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import cint, cstr, date_diff, flt, formatdate, getdate, get_link_to_form, \ - comma_or, get_fullname, add_days, nowdate, get_datetime_str +from frappe.utils import cint, cstr, date_diff, flt, formatdate, getdate, get_link_to_form, get_fullname, add_days, nowdate from erpnext.hr.utils import set_employee_name, get_leave_period, share_doc_with_approver from erpnext.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee @@ -85,7 +84,7 @@ class LeaveApplication(Document): def validate_dates(self): if frappe.db.get_single_value("HR Settings", "restrict_backdated_leave_application"): - if self.from_date and self.from_date < frappe.utils.today(): + if self.from_date and getdate(self.from_date) < getdate(): allowed_role = frappe.db.get_single_value("HR Settings", "role_allowed_to_create_backdated_leave_application") if allowed_role not in frappe.get_roles(): frappe.throw(_("Only users with the {0} role can create backdated leave applications").format(allowed_role)) @@ -248,9 +247,9 @@ class LeaveApplication(Document): self.throw_overlap_error(d) def throw_overlap_error(self, d): - msg = _("Employee {0} has already applied for {1} between {2} and {3} : ").format(self.employee, - d['leave_type'], formatdate(d['from_date']), formatdate(d['to_date'])) \ - + """ {0}""".format(d["name"]) + form_link = get_link_to_form("Leave Application", d.name) + msg = _("Employee {0} has already applied for {1} between {2} and {3} : {4}").format(self.employee, + d['leave_type'], formatdate(d['from_date']), formatdate(d['to_date']), form_link) frappe.throw(msg, OverlapError) def get_total_leaves_on_half_day(self): @@ -356,7 +355,7 @@ class LeaveApplication(Document): sender = dict() sender['email'] = frappe.get_doc('User', frappe.session.user).email - sender['full_name'] = frappe.utils.get_fullname(sender['email']) + sender['full_name'] = get_fullname(sender['email']) try: frappe.sendmail( @@ -823,4 +822,4 @@ def get_leave_approver(employee): leave_approver = frappe.db.get_value('Department Approver', {'parent': department, 'parentfield': 'leave_approvers', 'idx': 1}, 'approver') - return leave_approver \ No newline at end of file + return leave_approver diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.js b/erpnext/hr/doctype/staffing_plan/staffing_plan.js index 04af2323c72..228391ba001 100644 --- a/erpnext/hr/doctype/staffing_plan/staffing_plan.js +++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.js @@ -103,4 +103,4 @@ var set_total_estimated_budget = function(frm) { }) frm.set_value('total_estimated_budget', estimated_budget); } -} \ No newline at end of file +}; diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py index 533149a8235..e6c783aca22 100644 --- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py +++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py @@ -41,7 +41,7 @@ class StaffingPlan(Document): detail.total_estimated_cost = 0 if detail.number_of_positions > 0: - if detail.vacancies > 0 and detail.estimated_cost_per_position: + if detail.vacancies and detail.estimated_cost_per_position: detail.total_estimated_cost = cint(detail.vacancies) * flt(detail.estimated_cost_per_position) self.total_estimated_budget += detail.total_estimated_cost @@ -76,12 +76,12 @@ class StaffingPlan(Document): if cint(staffing_plan_detail.vacancies) > cint(parent_plan_details[0].vacancies) or \ flt(staffing_plan_detail.total_estimated_cost) > 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}." - .format(cint(parent_plan_details[0].vacancies), + 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, frappe.bold(staffing_plan_detail.designation), parent_plan_details[0].name, - parent_company)), ParentCompanyError) + parent_company), ParentCompanyError) #Get vacanices already planned for all companies down the hierarchy of Parent Company lft, rgt = frappe.get_cached_value('Company', parent_company, ["lft", "rgt"]) @@ -98,14 +98,14 @@ class StaffingPlan(Document): (flt(parent_plan_details[0].total_estimated_cost) < \ (flt(staffing_plan_detail.total_estimated_cost) + flt(all_sibling_details.total_estimated_cost))): 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}." - .format(cint(all_sibling_details.vacancies), + 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, frappe.bold(staffing_plan_detail.designation), parent_company, cint(parent_plan_details[0].vacancies), parent_plan_details[0].total_estimated_cost, - parent_plan_details[0].name))) + parent_plan_details[0].name)) def validate_with_subsidiary_plans(self, staffing_plan_detail): #Valdate this plan with all child company plan @@ -121,11 +121,11 @@ class StaffingPlan(Document): cint(staffing_plan_detail.vacancies) < cint(children_details.vacancies) or \ flt(staffing_plan_detail.total_estimated_cost) < flt(children_details.total_estimated_cost): 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" - .format(self.company, + 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), children_details.total_estimated_cost, - frappe.bold(staffing_plan_detail.designation))), SubsidiaryCompanyError) + frappe.bold(staffing_plan_detail.designation)), SubsidiaryCompanyError) @frappe.whitelist() def get_designation_counts(designation, company): @@ -170,4 +170,4 @@ def get_active_staffing_plan_details(company, designation, from_date=getdate(now designation, from_date, to_date) # Only a single staffing plan can be active for a designation on given date - return staffing_plan if staffing_plan else None \ No newline at end of file + return staffing_plan if staffing_plan else None diff --git a/erpnext/hr/doctype/training_event/training_event.js b/erpnext/hr/doctype/training_event/training_event.js index b7d34b178a0..d5f6e5f573e 100644 --- a/erpnext/hr/doctype/training_event/training_event.js +++ b/erpnext/hr/doctype/training_event/training_event.js @@ -20,11 +20,10 @@ frappe.ui.form.on('Training Event', { frappe.set_route("List", "Training Feedback"); }); } - } -}); + frm.events.set_employee_query(frm); + }, -frappe.ui.form.on("Training Event Employee", { - employee: function (frm) { + set_employee_query: function(frm) { let emp = []; for (let d in frm.doc.employees) { if (frm.doc.employees[d].employee) { @@ -34,9 +33,17 @@ frappe.ui.form.on("Training Event Employee", { frm.set_query("employee", "employees", function () { return { filters: { - name: ["NOT IN", emp] + name: ["NOT IN", emp], + status: "Active" } }; }); } }); + +frappe.ui.form.on("Training Event Employee", { + employee: function(frm) { + frm.events.set_employee_query(frm); + } +}); + diff --git a/erpnext/hr/doctype/training_event_employee/training_event_employee.json b/erpnext/hr/doctype/training_event_employee/training_event_employee.json index 2d313e9faca..bcb7d5e5bc0 100644 --- a/erpnext/hr/doctype/training_event_employee/training_event_employee.json +++ b/erpnext/hr/doctype/training_event_employee/training_event_employee.json @@ -19,6 +19,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Employee", + "no_copy": 1, "options": "Employee" }, { @@ -68,7 +69,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-21 12:41:59.336237", + "modified": "2021-07-02 17:20:27.630176", "modified_by": "Administrator", "module": "HR", "name": "Training Event Employee", diff --git a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py index cf0048c1a76..ed52c4e1222 100644 --- a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py +++ b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import unittest -from frappe.utils import nowdate,flt, cstr,random_string +from frappe.utils import nowdate, flt, cstr, random_string from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.vehicle_log.vehicle_log import make_expense_claim @@ -18,23 +18,13 @@ class TestVehicleLog(unittest.TestCase): self.employee_id = make_employee("testdriver@example.com", company="_Test Company") self.license_plate = get_vehicle(self.employee_id) - + def tearDown(self): frappe.delete_doc("Vehicle", self.license_plate, force=1) frappe.delete_doc("Employee", self.employee_id, force=1) def test_make_vehicle_log_and_syncing_of_odometer_value(self): - vehicle_log = frappe.get_doc({ - "doctype": "Vehicle Log", - "license_plate": cstr(self.license_plate), - "employee": self.employee_id, - "date":frappe.utils.nowdate(), - "odometer":5010, - "fuel_qty":frappe.utils.flt(50), - "price": frappe.utils.flt(500) - }) - vehicle_log.save() - vehicle_log.submit() + vehicle_log = make_vehicle_log(self.license_plate, self.employee_id) #checking value of vehicle odometer value on submit. vehicle = frappe.get_doc("Vehicle", self.license_plate) @@ -51,19 +41,9 @@ class TestVehicleLog(unittest.TestCase): self.assertEqual(vehicle.last_odometer, current_odometer - distance_travelled) vehicle_log.delete() - + def test_vehicle_log_fuel_expense(self): - vehicle_log = frappe.get_doc({ - "doctype": "Vehicle Log", - "license_plate": cstr(self.license_plate), - "employee": self.employee_id, - "date": frappe.utils.nowdate(), - "odometer":5010, - "fuel_qty":frappe.utils.flt(50), - "price": frappe.utils.flt(500) - }) - vehicle_log.save() - vehicle_log.submit() + vehicle_log = make_vehicle_log(self.license_plate, self.employee_id) expense_claim = make_expense_claim(vehicle_log.name) fuel_expense = expense_claim.expenses[0].amount @@ -73,6 +53,18 @@ class TestVehicleLog(unittest.TestCase): frappe.delete_doc("Expense Claim", expense_claim.name) frappe.delete_doc("Vehicle Log", vehicle_log.name) + def test_vehicle_log_with_service_expenses(self): + vehicle_log = make_vehicle_log(self.license_plate, self.employee_id, with_services=True) + + expense_claim = make_expense_claim(vehicle_log.name) + expenses = expense_claim.expenses[0].amount + self.assertEqual(expenses, 27000) + + vehicle_log.cancel() + frappe.delete_doc("Expense Claim", expense_claim.name) + frappe.delete_doc("Vehicle Log", vehicle_log.name) + + def get_vehicle(employee_id): license_plate=random_string(10).upper() vehicle = frappe.get_doc({ @@ -81,15 +73,46 @@ def get_vehicle(employee_id): "make": "Maruti", "model": "PCM", "employee": employee_id, - "last_odometer":5000, - "acquisition_date":frappe.utils.nowdate(), + "last_odometer": 5000, + "acquisition_date": nowdate(), "location": "Mumbai", "chassis_no": "1234ABCD", "uom": "Litre", - "vehicle_value":frappe.utils.flt(500000) + "vehicle_value": flt(500000) }) try: vehicle.insert() except frappe.DuplicateEntryError: pass - return license_plate \ No newline at end of file + return license_plate + + +def make_vehicle_log(license_plate, employee_id, with_services=False): + vehicle_log = frappe.get_doc({ + "doctype": "Vehicle Log", + "license_plate": cstr(license_plate), + "employee": employee_id, + "date": nowdate(), + "odometer": 5010, + "fuel_qty": flt(50), + "price": flt(500) + }) + + if with_services: + vehicle_log.append("service_detail", { + "service_item": "Oil Change", + "type": "Inspection", + "frequency": "Mileage", + "expense_amount": flt(500) + }) + vehicle_log.append("service_detail", { + "service_item": "Wheels", + "type": "Change", + "frequency": "Half Yearly", + "expense_amount": flt(1500) + }) + + vehicle_log.save() + vehicle_log.submit() + + return vehicle_log \ No newline at end of file diff --git a/erpnext/hr/doctype/vehicle_log/vehicle_log.json b/erpnext/hr/doctype/vehicle_log/vehicle_log.json index 619e295ebe8..4ea904542d8 100644 --- a/erpnext/hr/doctype/vehicle_log/vehicle_log.json +++ b/erpnext/hr/doctype/vehicle_log/vehicle_log.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "naming_series:", "creation": "2016-09-03 14:14:51.788550", "doctype": "DocType", @@ -10,7 +11,6 @@ "naming_series", "license_plate", "employee", - "column_break_4", "column_break_7", "model", "make", @@ -65,10 +65,6 @@ "options": "Employee", "reqd": 1 }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, { "fieldname": "column_break_7", "fieldtype": "Column Break" @@ -142,7 +138,6 @@ { "fieldname": "service_detail", "fieldtype": "Table", - "label": "Service Detail", "options": "Vehicle Service" }, { @@ -158,7 +153,7 @@ "fetch_from": "license_plate.last_odometer", "fieldname": "last_odometer", "fieldtype": "Int", - "label": "last Odometer Value ", + "label": "Last Odometer Value ", "read_only": 1, "reqd": 1 }, @@ -168,7 +163,8 @@ } ], "is_submittable": 1, - "modified": "2020-03-18 16:45:45.060761", + "links": [], + "modified": "2021-05-17 00:10:21.188352", "modified_by": "Administrator", "module": "HR", "name": "Vehicle Log", diff --git a/erpnext/hr/notification/training_scheduled/training_scheduled.json b/erpnext/hr/notification/training_scheduled/training_scheduled.json index e49541e3214..f3650038fd6 100644 --- a/erpnext/hr/notification/training_scheduled/training_scheduled.json +++ b/erpnext/hr/notification/training_scheduled/training_scheduled.json @@ -11,8 +11,8 @@ "event": "Submit", "idx": 0, "is_standard": 1, - "message": "\n \n \n \n \n \n \n \n
\n
\n {{_(\"Training Event:\")}} {{ doc.event_name }}\n
\n
\n\n\n \n \n \n \n \n \n \n
\n
\n {{ doc.introduction }}\n
    \n
  • {{_(\"Event Location\")}}: {{ doc.location }}
  • \n {% set start = frappe.utils.get_datetime(doc.start_time) %}\n {% set end = frappe.utils.get_datetime(doc.end_time) %}\n {% if start.date() == end.date() %}\n
  • {{_(\"Date\")}}: {{ start.strftime(\"%A, %d %b %Y\") }}
  • \n
  • \n {{_(\"Timing\")}}: {{ start.strftime(\"%I:%M %p\") + ' to ' + end.strftime(\"%I:%M %p\") }}\n
  • \n {% else %}\n
  • {{_(\"Start Time\")}}: {{ start.strftime(\"%A, %d %b %Y at %I:%M %p\") }}\n
  • \n
  • {{_(\"End Time\")}}: {{ end.strftime(\"%A, %d %b %Y at %I:%M %p\") }}\n
  • \n {% endif %}\n
  • {{ _('Event Link') }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}
  • \n {% if doc.is_mandatory %}\n
  • Note: This Training Event is mandatory
  • \n {% endif %}\n
\n
\n
", - "modified": "2021-05-24 16:29:13.165930", + "message": "\n \n \n \n \n \n \n \n
\n
\n {{_(\"Training Event:\")}} {{ doc.event_name }}\n
\n
\n\n\n \n \n \n \n \n \n \n
\n
\n {{ doc.introduction }}\n
    \n
  • {{_(\"Event Location\")}}: {{ doc.location }}
  • \n {% set start = frappe.utils.get_datetime(doc.start_time) %}\n {% set end = frappe.utils.get_datetime(doc.end_time) %}\n {% if start.date() == end.date() %}\n
  • {{_(\"Date\")}}: {{ start.strftime(\"%A, %d %b %Y\") }}
  • \n
  • \n {{_(\"Timing\")}}: {{ start.strftime(\"%I:%M %p\") + ' to ' + end.strftime(\"%I:%M %p\") }}\n
  • \n {% else %}\n
  • \n {{_(\"Start Time\")}}: {{ start.strftime(\"%A, %d %b %Y at %I:%M %p\") }}\n
  • \n
  • {{_(\"End Time\")}}: {{ end.strftime(\"%A, %d %b %Y at %I:%M %p\") }}
  • \n {% endif %}\n
  • {{ _(\"Event Link\") }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}
  • \n {% if doc.is_mandatory %}\n
  • {{ _(\"Note: This Training Event is mandatory\") }}
  • \n {% endif %}\n
\n
\n
", + "modified": "2021-06-16 14:08:12.933367", "modified_by": "Administrator", "module": "HR", "name": "Training Scheduled", diff --git a/erpnext/hr/notification/training_scheduled/training_scheduled.md b/erpnext/hr/notification/training_scheduled/training_scheduled.md index 418fd4990e3..b9ba846be56 100644 --- a/erpnext/hr/notification/training_scheduled/training_scheduled.md +++ b/erpnext/hr/notification/training_scheduled/training_scheduled.md @@ -24,19 +24,19 @@ {% set start = frappe.utils.get_datetime(doc.start_time) %} {% set end = frappe.utils.get_datetime(doc.end_time) %} {% if start.date() == end.date() %} -
  • {{_("Date")}}: {{ start.strftime("%A, %d %b %Y") }}
  • -
  • - {{_("Timing")}}: {{ start.strftime("%I:%M %p") + ' to ' + end.strftime("%I:%M %p") }} -
  • +
  • {{_("Date")}}: {{ start.strftime("%A, %d %b %Y") }}
  • +
  • + {{_("Timing")}}: {{ start.strftime("%I:%M %p") + ' to ' + end.strftime("%I:%M %p") }} +
  • {% else %} -
  • {{_("Start Time")}}: {{ start.strftime("%A, %d %b %Y at %I:%M %p") }} -
  • -
  • {{_("End Time")}}: {{ end.strftime("%A, %d %b %Y at %I:%M %p") }} -
  • +
  • + {{_("Start Time")}}: {{ start.strftime("%A, %d %b %Y at %I:%M %p") }} +
  • +
  • {{_("End Time")}}: {{ end.strftime("%A, %d %b %Y at %I:%M %p") }}
  • {% endif %} -
  • {{ _('Event Link') }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}
  • +
  • {{ _("Event Link") }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}
  • {% if doc.is_mandatory %} -
  • Note: This Training Event is mandatory
  • +
  • {{ _("Note: This Training Event is mandatory") }}
  • {% endif %} @@ -44,4 +44,4 @@ - \ No newline at end of file + diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.js b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.js index 05728a297b2..8bb3457190e 100644 --- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.js +++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.js @@ -37,5 +37,22 @@ frappe.query_reports["Employee Leave Balance"] = { "fieldtype": "Link", "options": "Employee", } - ] + ], + + onload: () => { + frappe.call({ + type: "GET", + method: "erpnext.hr.utils.get_leave_period", + args: { + "from_date": frappe.defaults.get_default("year_start_date"), + "to_date": frappe.defaults.get_default("year_end_date"), + "company": frappe.defaults.get_user_default("Company") + }, + freeze: true, + callback: (data) => { + frappe.query_report.set_filter_value("from_date", data.message[0].from_date); + frappe.query_report.set_filter_value("to_date", data.message[0].to_date); + } + }); + } } diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py index 06f9160363c..b8953b3eaac 100644 --- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py +++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py @@ -6,15 +6,16 @@ import frappe from frappe.utils import flt, add_days from frappe import _ from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period, get_leave_balance_on +from itertools import groupby def execute(filters=None): if filters.to_date <= filters.from_date: - frappe.throw(_('"From date" can not be greater than or equal to "To date"')) + frappe.throw(_('"From Date" can not be greater than or equal to "To Date"')) columns = get_columns() data = get_data(filters) - - return columns, data + charts = get_chart_data(data) + return columns, data, None, charts def get_columns(): columns = [{ @@ -31,9 +32,10 @@ def get_columns(): 'options': 'Employee' }, { 'label': _('Employee Name'), - 'fieldtype': 'Data', + 'fieldtype': 'Dynamic Link', 'fieldname': 'employee_name', 'width': 100, + 'options': 'employee' }, { 'label': _('Opening Balance'), 'fieldtype': 'float', @@ -64,8 +66,7 @@ def get_columns(): return columns def get_data(filters): - leave_types = frappe.db.sql_list("SELECT `name` FROM `tabLeave Type` ORDER BY `name` ASC") - + leave_types = frappe.db.get_list('Leave Type', pluck='name', order_by='name') conditions = get_conditions(filters) user = frappe.session.user @@ -113,12 +114,8 @@ def get_data(filters): # not be shown on the basis of days left it create in user mind for carry_forward leave row.closing_balance = (new_allocation + opening - (row.leaves_expired + leaves_taken)) - - row.indent = 1 data.append(row) - new_leaves_allocated = 0 - return data @@ -129,27 +126,37 @@ def get_conditions(filters): if filters.get('employee'): conditions['name'] = filters.get('employee') - if filters.get('employee'): - conditions['name'] = filters.get('employee') - if filters.get('company'): conditions['company'] = filters.get('company') + if filters.get('department'): + conditions['department'] = filters.get('department') + return conditions def get_department_leave_approver_map(department=None): - conditions='' - if department: - conditions="and (department_name = '%(department)s' or parent_department = '%(department)s')"%{'department': department} # get current department and all its child - department_list = frappe.db.sql_list(""" SELECT name FROM `tabDepartment` WHERE disabled=0 {0}""".format(conditions)) #nosec - + department_list = frappe.get_list('Department', + filters={ + 'disabled': 0 + }, + or_filters={ + 'name': department, + 'parent_department': department + }, + fields=['name'], + pluck='name' + ) # retrieve approvers list from current department and from its subsequent child departments - approver_list = frappe.get_all('Department Approver', filters={ - 'parentfield': 'leave_approvers', - 'parent': ('in', department_list) - }, fields=['parent', 'approver'], as_list=1) + approver_list = frappe.get_all('Department Approver', + filters={ + 'parentfield': 'leave_approvers', + 'parent': ('in', department_list) + }, + fields=['parent', 'approver'], + as_list=1 + ) approvers = {} @@ -171,7 +178,7 @@ def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type): is_carry_forward, is_expired FROM `tabLeave Ledger Entry` WHERE employee=%(employee)s AND leave_type=%(leave_type)s - AND docstatus=1 AND leaves>0 + 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)) @@ -190,3 +197,40 @@ def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type): new_allocation += record.leaves return new_allocation, expired_leaves + +def get_chart_data(data): + labels = [] + datasets = [] + employee_data = data + + if data and data[0].get('employee_name'): + get_dataset_for_chart(employee_data, datasets, labels) + + chart = { + 'data': { + 'labels': labels, + 'datasets': datasets + }, + 'type': 'bar', + 'colors': ['#456789', '#EE8888', '#7E77BF'] + } + + return chart + +def get_dataset_for_chart(employee_data, datasets, labels): + leaves = [] + employee_data = sorted(employee_data, key=lambda k: k['employee_name']) + + for key, group in groupby(employee_data, lambda x: x['employee_name']): + for grp in group: + if grp.closing_balance: + leaves.append(frappe._dict({ + 'leave_type': grp.leave_type, + 'closing_balance': grp.closing_balance + })) + + if leaves: + labels.append(key) + + for leave in leaves: + datasets.append({'name': leave.leave_type, 'values': [leave.closing_balance]}) diff --git a/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py b/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py new file mode 100644 index 00000000000..26e0f26392e --- /dev/null +++ b/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py @@ -0,0 +1,73 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import unittest +import frappe +from frappe.utils import getdate +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.vehicle_log.vehicle_log import make_expense_claim +from erpnext.hr.doctype.vehicle_log.test_vehicle_log import get_vehicle, make_vehicle_log +from erpnext.hr.report.vehicle_expenses.vehicle_expenses import execute +from erpnext.accounts.utils import get_fiscal_year + +class TestVehicleExpenses(unittest.TestCase): + @classmethod + def setUpClass(self): + frappe.db.sql('delete from `tabVehicle Log`') + + employee_id = frappe.db.sql('''select name from `tabEmployee` where name="testdriver@example.com"''') + self.employee_id = employee_id[0][0] if employee_id else None + if not self.employee_id: + self.employee_id = make_employee('testdriver@example.com', company='_Test Company') + + self.license_plate = get_vehicle(self.employee_id) + + def test_vehicle_expenses_based_on_fiscal_year(self): + vehicle_log = make_vehicle_log(self.license_plate, self.employee_id, with_services=True) + expense_claim = make_expense_claim(vehicle_log.name) + + # Based on Fiscal Year + filters = { + 'filter_based_on': 'Fiscal Year', + 'fiscal_year': get_fiscal_year(getdate())[0] + } + + report = execute(filters) + + expected_data = [{ + 'vehicle': self.license_plate, + 'make': 'Maruti', + 'model': 'PCM', + 'location': 'Mumbai', + 'log_name': vehicle_log.name, + 'odometer': 5010, + 'date': getdate(), + 'fuel_qty': 50.0, + 'fuel_price': 500.0, + 'fuel_expense': 25000.0, + 'service_expense': 2000.0, + 'employee': self.employee_id + }] + + self.assertEqual(report[1], expected_data) + + # Based on Date Range + fiscal_year = get_fiscal_year(getdate(), as_dict=True) + filters = { + 'filter_based_on': 'Date Range', + 'from_date': fiscal_year.year_start_date, + 'to_date': fiscal_year.year_end_date + } + + report = execute(filters) + self.assertEqual(report[1], expected_data) + + # clean up + vehicle_log.cancel() + frappe.delete_doc('Expense Claim', expense_claim.name) + frappe.delete_doc('Vehicle Log', vehicle_log.name) + + def tearDown(self): + frappe.delete_doc('Vehicle', self.license_plate, force=1) + frappe.delete_doc('Employee', self.employee_id, force=1) diff --git a/erpnext/hr/report/vehicle_expenses/vehicle_expenses.js b/erpnext/hr/report/vehicle_expenses/vehicle_expenses.js index b66bebbec1f..879acd18ef4 100644 --- a/erpnext/hr/report/vehicle_expenses/vehicle_expenses.js +++ b/erpnext/hr/report/vehicle_expenses/vehicle_expenses.js @@ -1,31 +1,52 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.require("assets/erpnext/js/financial_statements.js", function() { - frappe.query_reports["Vehicle Expenses"] = { - "filters": [ - { - "fieldname": "fiscal_year", - "label": __("Fiscal Year"), - "fieldtype": "Link", - "options": "Fiscal Year", - "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1, - "on_change": function(query_report) { - var fiscal_year = query_report.get_values().fiscal_year; - if (!fiscal_year) { - return; - } - frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) { - var fy = frappe.model.get_doc("Fiscal Year", fiscal_year); - - frappe.query_report.set_filter({ - from_date: fy.year_start_date, - to_date: fy.year_end_date - }); - }); - } - } - ] - } -}); +frappe.query_reports["Vehicle Expenses"] = { + "filters": [ + { + "fieldname": "filter_based_on", + "label": __("Filter Based On"), + "fieldtype": "Select", + "options": ["Fiscal Year", "Date Range"], + "default": ["Fiscal Year"], + "reqd": 1 + }, + { + "fieldname": "fiscal_year", + "label": __("Fiscal Year"), + "fieldtype": "Link", + "options": "Fiscal Year", + "default": frappe.defaults.get_user_default("fiscal_year"), + "depends_on": "eval: doc.filter_based_on == 'Fiscal Year'", + "reqd": 1 + }, + { + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date", + "reqd": 1, + "depends_on": "eval: doc.filter_based_on == 'Date Range'", + "default": frappe.datetime.add_months(frappe.datetime.nowdate(), -12) + }, + { + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date", + "reqd": 1, + "depends_on": "eval: doc.filter_based_on == 'Date Range'", + "default": frappe.datetime.nowdate() + }, + { + "fieldname": "vehicle", + "label": __("Vehicle"), + "fieldtype": "Link", + "options": "Vehicle" + }, + { + "fieldname": "employee", + "label": __("Employee"), + "fieldtype": "Link", + "options": "Employee" + } + ] +}; diff --git a/erpnext/hr/report/vehicle_expenses/vehicle_expenses.json b/erpnext/hr/report/vehicle_expenses/vehicle_expenses.json index 2ab0c143b8b..1a3e5a93bb2 100644 --- a/erpnext/hr/report/vehicle_expenses/vehicle_expenses.json +++ b/erpnext/hr/report/vehicle_expenses/vehicle_expenses.json @@ -1,20 +1,23 @@ { - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2016-09-09 03:33:40.605734", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 2, - "is_standard": "Yes", - "modified": "2017-02-24 19:59:18.641284", - "modified_by": "Administrator", - "module": "HR", - "name": "Vehicle Expenses", - "owner": "Administrator", - "ref_doctype": "Vehicle", - "report_name": "Vehicle Expenses", - "report_type": "Script Report", + "add_total_row": 1, + "columns": [], + "creation": "2016-09-09 03:33:40.605734", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 2, + "is_standard": "Yes", + "modified": "2021-05-16 22:48:22.767535", + "modified_by": "Administrator", + "module": "HR", + "name": "Vehicle Expenses", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Vehicle", + "report_name": "Vehicle Expenses", + "report_type": "Script Report", "roles": [ { "role": "Fleet Manager" diff --git a/erpnext/hr/report/vehicle_expenses/vehicle_expenses.py b/erpnext/hr/report/vehicle_expenses/vehicle_expenses.py index eab58ffbbcf..d847cbb5c9b 100644 --- a/erpnext/hr/report/vehicle_expenses/vehicle_expenses.py +++ b/erpnext/hr/report/vehicle_expenses/vehicle_expenses.py @@ -5,86 +5,209 @@ from __future__ import unicode_literals import frappe import erpnext from frappe import _ -from frappe.utils import flt,cstr +from frappe.utils import flt from erpnext.accounts.report.financial_statements import get_period_list def execute(filters=None): - columns, data, chart = [], [], [] - if filters.get('fiscal_year'): - company = erpnext.get_default_company() - period_list = get_period_list(filters.get('fiscal_year'), filters.get('fiscal_year'), - '', '', 'Fiscal Year', 'Monthly', company=company) - columns=get_columns() - data=get_log_data(filters) - chart=get_chart_data(data,period_list) + filters = frappe._dict(filters or {}) + + columns = get_columns() + data = get_vehicle_log_data(filters) + chart = get_chart_data(data, filters) + return columns, data, None, chart def get_columns(): - columns = [_("License") + ":Link/Vehicle:100", _('Create') + ":data:50", - _("Model") + ":data:50", _("Location") + ":data:100", - _("Log") + ":Link/Vehicle Log:100", _("Odometer") + ":Int:80", - _("Date") + ":Date:100", _("Fuel Qty") + ":Float:80", - _("Fuel Price") + ":Float:100",_("Fuel Expense") + ":Float:100", - _("Service Expense") + ":Float:100" + return [ + { + 'fieldname': 'vehicle', + 'fieldtype': 'Link', + 'label': _('Vehicle'), + 'options': 'Vehicle', + 'width': 150 + }, + { + 'fieldname': 'make', + 'fieldtype': 'Data', + 'label': _('Make'), + 'width': 100 + }, + { + 'fieldname': 'model', + 'fieldtype': 'Data', + 'label': _('Model'), + 'width': 80 + }, + { + 'fieldname': 'location', + 'fieldtype': 'Data', + 'label': _('Location'), + 'width': 100 + }, + { + 'fieldname': 'log_name', + 'fieldtype': 'Link', + 'label': _('Vehicle Log'), + 'options': 'Vehicle Log', + 'width': 100 + }, + { + 'fieldname': 'odometer', + 'fieldtype': 'Int', + 'label': _('Odometer Value'), + 'width': 120 + }, + { + 'fieldname': 'date', + 'fieldtype': 'Date', + 'label': _('Date'), + 'width': 100 + }, + { + 'fieldname': 'fuel_qty', + 'fieldtype': 'Float', + 'label': _('Fuel Qty'), + 'width': 80 + }, + { + 'fieldname': 'fuel_price', + 'fieldtype': 'Float', + 'label': _('Fuel Price'), + 'width': 100 + }, + { + 'fieldname': 'fuel_expense', + 'fieldtype': 'Currency', + 'label': _('Fuel Expense'), + 'width': 150 + }, + { + 'fieldname': 'service_expense', + 'fieldtype': 'Currency', + 'label': _('Service Expense'), + 'width': 150 + }, + { + 'fieldname': 'employee', + 'fieldtype': 'Link', + 'label': _('Employee'), + 'options': 'Employee', + 'width': 150 + } ] + return columns -def get_log_data(filters): - fy = frappe.db.get_value('Fiscal Year', filters.get('fiscal_year'), ['year_start_date', 'year_end_date'], as_dict=True) - data = frappe.db.sql("""select - vhcl.license_plate as "License", vhcl.make as "Make", vhcl.model as "Model", - vhcl.location as "Location", log.name as "Log", log.odometer as "Odometer", - log.date as "Date", log.fuel_qty as "Fuel Qty", log.price as "Fuel Price", - log.fuel_qty * log.price as "Fuel Expense" - from + +def get_vehicle_log_data(filters): + start_date, end_date = get_period_dates(filters) + conditions, values = get_conditions(filters) + + data = frappe.db.sql(""" + SELECT + vhcl.license_plate as vehicle, vhcl.make, vhcl.model, + vhcl.location, log.name as log_name, log.odometer, + log.date, log.employee, log.fuel_qty, + log.price as fuel_price, + log.fuel_qty * log.price as fuel_expense + FROM `tabVehicle` vhcl,`tabVehicle Log` log - where - vhcl.license_plate = log.license_plate and log.docstatus = 1 and date between %s and %s - order by date""" ,(fy.year_start_date, fy.year_end_date), as_dict=1) - dl=list(data) - for row in dl: - row["Service Expense"]= get_service_expense(row["Log"]) - return dl + WHERE + vhcl.license_plate = log.license_plate + and log.docstatus = 1 + and date between %(start_date)s and %(end_date)s + {0} + ORDER BY date""".format(conditions), values, as_dict=1) + + for row in data: + row['service_expense'] = get_service_expense(row.log_name) + + return data + + +def get_conditions(filters): + conditions = '' + + start_date, end_date = get_period_dates(filters) + values = { + 'start_date': start_date, + 'end_date': end_date + } + + if filters.employee: + conditions += ' and log.employee = %(employee)s' + values['employee'] = filters.employee + + if filters.vehicle: + conditions += ' and vhcl.license_plate = %(vehicle)s' + values['vehicle'] = filters.vehicle + + return conditions, values + + +def get_period_dates(filters): + if filters.filter_based_on == 'Fiscal Year' and filters.fiscal_year: + fy = frappe.db.get_value('Fiscal Year', filters.fiscal_year, + ['year_start_date', 'year_end_date'], as_dict=True) + return fy.year_start_date, fy.year_end_date + else: + return filters.from_date, filters.to_date + def get_service_expense(logname): - expense_amount = frappe.db.sql("""select sum(expense_amount) - from `tabVehicle Log` log,`tabVehicle Service` ser - where ser.parent=log.name and log.name=%s""",logname) - return flt(expense_amount[0][0]) if expense_amount else 0 + expense_amount = frappe.db.sql(""" + SELECT sum(expense_amount) + FROM + `tabVehicle Log` log, `tabVehicle Service` service + WHERE + service.parent=log.name and log.name=%s + """, logname) + + return flt(expense_amount[0][0]) if expense_amount else 0.0 + + +def get_chart_data(data, filters): + period_list = get_period_list(filters.fiscal_year, filters.fiscal_year, + filters.from_date, filters.to_date, filters.filter_based_on, 'Monthly') + + fuel_data, service_data = [], [] -def get_chart_data(data,period_list): - fuel_exp_data,service_exp_data,fueldata,servicedata = [],[],[],[] - service_exp_data = [] - fueldata = [] for period in period_list: - total_fuel_exp=0 - total_ser_exp=0 - for row in data: - if row["Date"] <= period.to_date and row["Date"] >= period.from_date: - total_fuel_exp+=flt(row["Fuel Expense"]) - total_ser_exp+=flt(row["Service Expense"]) - fueldata.append([period.key,total_fuel_exp]) - servicedata.append([period.key,total_ser_exp]) + total_fuel_exp = 0 + total_service_exp = 0 + + for row in data: + if row.date <= period.to_date and row.date >= period.from_date: + total_fuel_exp += flt(row.fuel_expense) + total_service_exp += flt(row.service_expense) + + fuel_data.append([period.key, total_fuel_exp]) + service_data.append([period.key, total_service_exp]) + + labels = [period.label for period in period_list] + fuel_exp_data= [row[1] for row in fuel_data] + service_exp_data= [row[1] for row in service_data] - labels = [period.key for period in period_list] - fuel_exp_data= [row[1] for row in fueldata] - service_exp_data= [row[1] for row in servicedata] datasets = [] if fuel_exp_data: datasets.append({ - 'name': 'Fuel Expenses', + 'name': _('Fuel Expenses'), 'values': fuel_exp_data }) + if service_exp_data: datasets.append({ - 'name': 'Service Expenses', + 'name': _('Service Expenses'), 'values': service_exp_data }) + chart = { - "data": { + 'data': { 'labels': labels, 'datasets': datasets - } + }, + 'type': 'line', + 'fieldtype': 'Currency' } - chart["type"] = "line" + return chart diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 80189e87b7a..ebb17343471 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -269,6 +269,7 @@ def get_total_exemption_amount(declarations): total_exemption_amount = sum([flt(d.total_exemption_amount) for d in exemptions.values()]) return total_exemption_amount +@frappe.whitelist() def get_leave_period(from_date, to_date, company): leave_period = frappe.db.sql(""" select name, from_date, to_date diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json index c5201c22c9b..4500ba4560c 100644 --- a/erpnext/hr/workspace/hr/hr.json +++ b/erpnext/hr/workspace/hr/hr.json @@ -153,6 +153,24 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Grievance Type", + "link_to": "Grievance Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Grievance", + "link_to": "Employee Grievance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "dependencies": "Employee", "hidden": 0, @@ -823,7 +841,7 @@ "type": "Link" } ], - "modified": "2021-04-26 13:36:15.413819", + "modified": "2021-05-13 17:19:40.524444", "modified_by": "Administrator", "module": "HR", "name": "HR", diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js index 28af3a9c415..f9c201ab603 100644 --- a/erpnext/loan_management/doctype/loan/loan.js +++ b/erpnext/loan_management/doctype/loan/loan.js @@ -28,7 +28,8 @@ frappe.ui.form.on('Loan', { frm.set_query("loan_type", function () { return { "filters": { - "docstatus": 1 + "docstatus": 1, + "company": frm.doc.company } }; }); diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index 230475f2d14..ff7fbbdf49a 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -60,8 +60,9 @@ class Loan(AccountsController): self.monthly_repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods) def check_sanctioned_amount_limit(self): - total_loan_amount = get_total_loan_amount(self.applicant_type, self.applicant, self.company) sanctioned_amount_limit = get_sanctioned_amount_limit(self.applicant_type, self.applicant, self.company) + if sanctioned_amount_limit: + total_loan_amount = get_total_loan_amount(self.applicant_type, self.applicant, self.company) if sanctioned_amount_limit and flt(self.loan_amount) + flt(total_loan_amount) > flt(sanctioned_amount_limit): frappe.throw(_("Sanctioned Amount limit crossed for {0} {1}").format(self.applicant_type, frappe.bold(self.applicant))) @@ -155,9 +156,29 @@ def update_total_amount_paid(doc): frappe.db.set_value("Loan", doc.name, "total_amount_paid", total_amount_paid) def get_total_loan_amount(applicant_type, applicant, company): - return frappe.db.get_value('Loan', - {'applicant_type': applicant_type, 'company': company, 'applicant': applicant, 'docstatus': 1}, - 'sum(loan_amount)') + pending_amount = 0 + loan_details = frappe.db.get_all("Loan", + filters={"applicant_type": applicant_type, "company": company, "applicant": applicant, "docstatus": 1, + "status": ("!=", "Closed")}, + fields=["status", "total_payment", "disbursed_amount", "total_interest_payable", "total_principal_paid", + "written_off_amount"]) + + interest_amount = flt(frappe.db.get_value("Loan Interest Accrual", {"applicant_type": applicant_type, + "company": company, "applicant": applicant, "docstatus": 1}, "sum(interest_amount - paid_interest_amount)")) + + for loan in loan_details: + if loan.status in ("Disbursed", "Loan Closure Requested"): + pending_amount += flt(loan.total_payment) - flt(loan.total_interest_payable) \ + - flt(loan.total_principal_paid) - flt(loan.written_off_amount) + elif loan.status == "Partially Disbursed": + pending_amount += flt(loan.disbursed_amount) - flt(loan.total_interest_payable) \ + - flt(loan.total_principal_paid) - flt(loan.written_off_amount) + elif loan.status == "Sanctioned": + pending_amount += flt(loan.total_payment) + + pending_amount += interest_amount + + return pending_amount def get_sanctioned_amount_limit(applicant_type, applicant, company): return frappe.db.get_value('Sanctioned Loan Amount', @@ -264,7 +285,7 @@ def make_loan_write_off(loan, company=None, posting_date=None, amount=0, as_dict pending_amount = amounts['pending_principal_amount'] if amount and (amount > pending_amount): - frappe.throw('Write Off amount cannot be greater than pending loan amount') + frappe.throw(_('Write Off amount cannot be greater than pending loan amount')) if not amount: amount = pending_amount diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py index fa4707ce2b4..314f58dd15e 100644 --- a/erpnext/loan_management/doctype/loan/test_loan.py +++ b/erpnext/loan_management/doctype/loan/test_loan.py @@ -49,7 +49,11 @@ class TestLoan(unittest.TestCase): if not frappe.db.exists("Customer", "_Test Loan Customer"): frappe.get_doc(get_customer_dict('_Test Loan Customer')).insert(ignore_permissions=True) - self.applicant2 = frappe.db.get_value("Customer", {'name': '_Test Loan Customer'}, 'name') + if not frappe.db.exists("Customer", "_Test Loan Customer 1"): + frappe.get_doc(get_customer_dict("_Test Loan Customer 1")).insert(ignore_permissions=True) + + self.applicant2 = frappe.db.get_value("Customer", {"name": "_Test Loan Customer"}, "name") + self.applicant3 = frappe.db.get_value("Customer", {"name": "_Test Loan Customer 1"}, "name") create_loan(self.applicant1, "Personal Loan", 280000, "Repay Over Number of Periods", 20) @@ -125,6 +129,38 @@ class TestLoan(unittest.TestCase): self.assertTrue(gl_entries1) self.assertTrue(gl_entries2) + def test_sanctioned_amount_limit(self): + # Clear loan docs before checking + frappe.db.sql("DELETE FROM `tabLoan` where applicant = '_Test Loan Customer 1'") + frappe.db.sql("DELETE FROM `tabLoan Application` where applicant = '_Test Loan Customer 1'") + frappe.db.sql("DELETE FROM `tabLoan Security Pledge` where applicant = '_Test Loan Customer 1'") + + if not frappe.db.get_value("Sanctioned Loan Amount", filters={"applicant_type": "Customer", + "applicant": "_Test Loan Customer 1", "company": "_Test Company"}): + frappe.get_doc({ + "doctype": "Sanctioned Loan Amount", + "applicant_type": "Customer", + "applicant": "_Test Loan Customer 1", + "sanctioned_amount_limit": 1500000, + "company": "_Test Company" + }).insert(ignore_permissions=True) + + # Make First Loan + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant3, 'Demand Loan', pledge) + create_pledge(loan_application) + loan = create_demand_loan(self.applicant3, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + # Make second loan greater than the sanctioned amount + loan_application = create_loan_application('_Test Company', self.applicant3, 'Demand Loan', pledge, + do_not_save=True) + self.assertRaises(frappe.ValidationError, loan_application.save) + def test_regular_loan_repayment(self): pledge = [{ "loan_security": "Test Security 1", @@ -367,7 +403,7 @@ class TestLoan(unittest.TestCase): unpledge_request.load_from_db() self.assertEqual(unpledge_request.docstatus, 1) - def test_santined_loan_security_unpledge(self): + def test_sanctioned_loan_security_unpledge(self): pledge = [{ "loan_security": "Test Security 1", "qty": 4000.00 @@ -858,7 +894,7 @@ def create_repayment_entry(loan, applicant, posting_date, paid_amount): return lr def create_loan_application(company, applicant, loan_type, proposed_pledges, repayment_method=None, - repayment_periods=None, posting_date=None): + repayment_periods=None, posting_date=None, do_not_save=False): loan_application = frappe.new_doc('Loan Application') loan_application.applicant_type = 'Customer' loan_application.company = company @@ -874,6 +910,9 @@ def create_loan_application(company, applicant, loan_type, proposed_pledges, rep for pledge in proposed_pledges: loan_application.append('proposed_pledges', pledge) + if do_not_save: + return loan_application + loan_application.save() loan_application.submit() diff --git a/erpnext/loan_management/doctype/loan_application/loan_application.js b/erpnext/loan_management/doctype/loan_application/loan_application.js index 13652749711..017026ca13f 100644 --- a/erpnext/loan_management/doctype/loan_application/loan_application.js +++ b/erpnext/loan_management/doctype/loan_application/loan_application.js @@ -14,11 +14,18 @@ frappe.ui.form.on('Loan Application', { refresh: function(frm) { frm.trigger("toggle_fields"); frm.trigger("add_toolbar_buttons"); + frm.set_query("loan_type", () => { + return { + filters: { + company: frm.doc.company + } + }; + }); }, repayment_method: function(frm) { - frm.doc.repayment_amount = frm.doc.repayment_periods = "" - frm.trigger("toggle_fields") - frm.trigger("toggle_required") + frm.doc.repayment_amount = frm.doc.repayment_periods = ""; + frm.trigger("toggle_fields"); + frm.trigger("toggle_required"); }, toggle_fields: function(frm) { frm.toggle_enable("repayment_amount", frm.doc.repayment_method=="Repay Fixed Amount per Period") diff --git a/erpnext/loan_management/doctype/loan_application/loan_application.py b/erpnext/loan_management/doctype/loan_application/loan_application.py index 9c0147e55ba..d8f3577b2c3 100644 --- a/erpnext/loan_management/doctype/loan_application/loan_application.py +++ b/erpnext/loan_management/doctype/loan_application/loan_application.py @@ -46,9 +46,11 @@ class LoanApplication(Document): frappe.throw(_("Loan Amount exceeds maximum loan amount of {0} as per proposed securities").format(self.maximum_loan_amount)) def check_sanctioned_amount_limit(self): - total_loan_amount = get_total_loan_amount(self.applicant_type, self.applicant, self.company) sanctioned_amount_limit = get_sanctioned_amount_limit(self.applicant_type, self.applicant, self.company) + if sanctioned_amount_limit: + total_loan_amount = get_total_loan_amount(self.applicant_type, self.applicant, self.company) + if sanctioned_amount_limit and flt(self.loan_amount) + flt(total_loan_amount) > flt(sanctioned_amount_limit): frappe.throw(_("Sanctioned Amount limit crossed for {0} {1}").format(self.applicant_type, frappe.bold(self.applicant))) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 3d99b1f3040..b8b1a40b5fd 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -235,70 +235,71 @@ class LoanRepayment(AccountsController): else: remarks = _("Repayment against Loan: ") + self.against_loan - if self.total_penalty_paid: + if not loan_details.repay_from_salary: + if self.total_penalty_paid: + gle_map.append( + self.get_gl_dict({ + "account": loan_details.loan_account, + "against": loan_details.payment_account, + "debit": self.total_penalty_paid, + "debit_in_account_currency": self.total_penalty_paid, + "against_voucher_type": "Loan", + "against_voucher": self.against_loan, + "remarks": _("Penalty against loan:") + self.against_loan, + "cost_center": self.cost_center, + "party_type": self.applicant_type, + "party": self.applicant, + "posting_date": getdate(self.posting_date) + }) + ) + + gle_map.append( + self.get_gl_dict({ + "account": loan_details.penalty_income_account, + "against": loan_details.payment_account, + "credit": self.total_penalty_paid, + "credit_in_account_currency": self.total_penalty_paid, + "against_voucher_type": "Loan", + "against_voucher": self.against_loan, + "remarks": _("Penalty against loan:") + self.against_loan, + "cost_center": self.cost_center, + "posting_date": getdate(self.posting_date) + }) + ) + gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "against": loan_details.payment_account, - "debit": self.total_penalty_paid, - "debit_in_account_currency": self.total_penalty_paid, + "account": loan_details.payment_account, + "against": loan_details.loan_account + ", " + loan_details.interest_income_account + + ", " + loan_details.penalty_income_account, + "debit": self.amount_paid, + "debit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Penalty against loan:") + self.against_loan, + "remarks": remarks, "cost_center": self.cost_center, - "party_type": self.applicant_type, - "party": self.applicant, "posting_date": getdate(self.posting_date) }) ) gle_map.append( self.get_gl_dict({ - "account": loan_details.penalty_income_account, + "account": loan_details.loan_account, + "party_type": loan_details.applicant_type, + "party": loan_details.applicant, "against": loan_details.payment_account, - "credit": self.total_penalty_paid, - "credit_in_account_currency": self.total_penalty_paid, + "credit": self.amount_paid, + "credit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Penalty against loan:") + self.against_loan, + "remarks": remarks, "cost_center": self.cost_center, "posting_date": getdate(self.posting_date) }) ) - gle_map.append( - self.get_gl_dict({ - "account": loan_details.payment_account, - "against": loan_details.loan_account + ", " + loan_details.interest_income_account - + ", " + loan_details.penalty_income_account, - "debit": self.amount_paid, - "debit_in_account_currency": self.amount_paid, - "against_voucher_type": "Loan", - "against_voucher": self.against_loan, - "remarks": remarks, - "cost_center": self.cost_center, - "posting_date": getdate(self.posting_date) - }) - ) - - gle_map.append( - self.get_gl_dict({ - "account": loan_details.loan_account, - "party_type": loan_details.applicant_type, - "party": loan_details.applicant, - "against": loan_details.payment_account, - "credit": self.amount_paid, - "credit_in_account_currency": self.amount_paid, - "against_voucher_type": "Loan", - "against_voucher": self.against_loan, - "remarks": remarks, - "cost_center": self.cost_center, - "posting_date": getdate(self.posting_date) - }) - ) - - if gle_map: - make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj, merge_entries=False) + if gle_map: + make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj, merge_entries=False) def create_repayment_entry(loan, applicant, company, posting_date, loan_type, payment_type, interest_payable, payable_principal_amount, amount_paid, penalty_amount=None): diff --git a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json index 18bd4aea78b..68bac8ed8cd 100644 --- a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json +++ b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json @@ -35,7 +35,9 @@ "no_copy": 1, "options": "Loan Security Pledge", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fetch_from": "loan_application.applicant", @@ -45,47 +47,63 @@ "in_standard_filter": 1, "label": "Applicant", "options": "applicant_type", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan_security_details_section", "fieldtype": "Section Break", - "label": "Loan Security Details" + "label": "Loan Security Details", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_3", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan", "fieldtype": "Link", "label": "Loan", - "options": "Loan" + "options": "Loan", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan_application", "fieldtype": "Link", "label": "Loan Application", - "options": "Loan Application" + "options": "Loan Application", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "total_security_value", "fieldtype": "Currency", "label": "Total Security Value", "options": "Company:company:default_currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "maximum_loan_value", "fieldtype": "Currency", "label": "Maximum Loan Value", "options": "Company:company:default_currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan_details_section", "fieldtype": "Section Break", - "label": "Loan Details" + "label": "Loan Details", + "show_days": 1, + "show_seconds": 1 }, { "default": "Requested", @@ -94,37 +112,49 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "Requested\nUnpledged\nPledged\nPartially Pledged", - "read_only": 1 + "options": "Requested\nUnpledged\nPledged\nPartially Pledged\nCancelled", + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "pledge_time", "fieldtype": "Datetime", "label": "Pledge Time", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "securities", "fieldtype": "Table", "label": "Securities", "options": "Pledge", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_11", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "section_break_10", "fieldtype": "Section Break", - "label": "Totals" + "label": "Totals", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "company", "fieldtype": "Link", "label": "Company", "options": "Company", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fetch_from": "loan.applicant_type", @@ -132,35 +162,45 @@ "fieldtype": "Select", "label": "Applicant Type", "options": "Employee\nMember\nCustomer", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "more_information_section", "fieldtype": "Section Break", - "label": "More Information" + "label": "More Information", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "reference_no", "fieldtype": "Data", - "label": "Reference No" + "label": "Reference No", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_18", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "description", "fieldtype": "Text", - "label": "Description" + "label": "Description", + "show_days": 1, + "show_seconds": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:23:16.953305", + "modified": "2021-06-29 17:15:16.082256", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Security Pledge", diff --git a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py index cbc8376aa56..c390b6c526d 100644 --- a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py +++ b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py @@ -23,6 +23,12 @@ class LoanSecurityPledge(Document): update_shortfall_status(self.loan, self.total_security_value) update_loan(self.loan, self.maximum_loan_value) + def on_cancel(self): + if self.loan: + self.db_set("status", "Cancelled") + self.db_set("pledge_time", None) + update_loan(self.loan, self.maximum_loan_value, cancel=1) + def validate_duplicate_securities(self): security_list = [] for security in self.securities: @@ -36,7 +42,7 @@ class LoanSecurityPledge(Document): existing_pledge = '' if self.loan: - existing_pledge = frappe.db.get_value('Loan Security Pledge', {'loan': self.loan}, ['name']) + existing_pledge = frappe.db.get_value('Loan Security Pledge', {'loan': self.loan, 'docstatus': 1}, ['name']) if existing_pledge: loan_security_type = frappe.db.get_value('Pledge', {'parent': existing_pledge}, ['loan_security_type']) @@ -77,8 +83,12 @@ class LoanSecurityPledge(Document): self.total_security_value = total_security_value self.maximum_loan_value = maximum_loan_value -def update_loan(loan, maximum_value_against_pledge): +def update_loan(loan, maximum_value_against_pledge, cancel=0): maximum_loan_value = frappe.db.get_value('Loan', {'name': loan}, ['maximum_loan_amount']) - frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s, is_secured_loan=1 - WHERE name=%s""", (maximum_loan_value + maximum_value_against_pledge, loan)) + if cancel: + frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s + WHERE name=%s""", (maximum_loan_value - maximum_value_against_pledge, loan)) + else: + frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s, is_secured_loan=1 + WHERE name=%s""", (maximum_loan_value + maximum_value_against_pledge, loan)) diff --git a/erpnext/loan_management/workspace/loan_management/loan_management.json b/erpnext/loan_management/workspace/loan_management/loan_management.json index 18559dceef7..d0b67f7c64a 100644 --- a/erpnext/loan_management/workspace/loan_management/loan_management.json +++ b/erpnext/loan_management/workspace/loan_management/loan_management.json @@ -12,7 +12,7 @@ "idx": 0, "is_default": 0, "is_standard": 1, - "label": "Loan Management", + "label": "Loans", "links": [ { "hidden": 0, @@ -220,10 +220,10 @@ "type": "Link" } ], - "modified": "2021-02-18 17:31:53.586508", + "modified": "2021-05-25 17:31:53.586508", "modified_by": "Administrator", "module": "Loan Management", - "name": "Loan Management", + "name": "Loans", "owner": "Administrator", "pin_to_bottom": 0, "pin_to_top": 0, diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js index 2371d9652cc..546a68f268b 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js @@ -2,40 +2,36 @@ // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.maintenance"); - frappe.ui.form.on('Maintenance Schedule', { - setup: function(frm) { + setup: function (frm) { frm.set_query('contact_person', erpnext.queries.contact_query); frm.set_query('customer_address', erpnext.queries.address_query); frm.set_query('customer', erpnext.queries.customer); - - frm.add_fetch('item_code', 'item_name', 'item_name'); - frm.add_fetch('item_code', 'description', 'description'); }, - onload: function(frm) { + onload: function (frm) { if (!frm.doc.status) { - frm.set_value({status:'Draft'}); + frm.set_value({ status: 'Draft' }); } if (frm.doc.__islocal) { - frm.set_value({transaction_date: frappe.datetime.get_today()}); + frm.set_value({ transaction_date: frappe.datetime.get_today() }); } }, - refresh: function(frm) { + refresh: function (frm) { setTimeout(() => { frm.toggle_display('generate_schedule', !(frm.is_new())); frm.toggle_display('schedule', !(frm.is_new())); - },10); + }, 10); }, - customer: function(frm) { + customer: function (frm) { erpnext.utils.get_party_details(frm) }, - customer_address: function(frm) { + customer_address: function (frm) { erpnext.utils.get_address_display(frm, 'customer_address', 'address_display'); }, - contact_person: function(frm) { + contact_person: function (frm) { erpnext.utils.get_contact_details(frm); }, - generate_schedule: function(frm) { + generate_schedule: function (frm) { if (frm.is_new()) { frappe.msgprint(__('Please save first')); } else { @@ -53,7 +49,7 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap if (this.frm.doc.docstatus === 0) { this.frm.add_custom_button(__('Sales Order'), - function() { + function () { erpnext.utils.map_current_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_schedule", source_doctype: "Sales Order", @@ -68,13 +64,79 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap }); }, __("Get Items From")); } else if (this.frm.doc.docstatus === 1) { - this.frm.add_custom_button(__('Create Maintenance Visit'), function() { - frappe.model.open_mapped_doc({ - method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.make_maintenance_visit", - source_name: me.frm.doc.name, - frm: me.frm + let schedules = me.frm.doc.schedules; + let flag = schedules.some(schedule => schedule.completion_status === "Pending"); + if (flag) { + this.frm.add_custom_button(__('Maintenance Visit'), function () { + let options = ""; + + me.frm.call('get_pending_data', {data_type: "items"}).then(r => { + options = r.message; + + let schedule_id = ""; + let d = new frappe.ui.Dialog({ + title: __("Enter Visit Details"), + fields: [{ + fieldtype: "Select", + fieldname: "item_name", + label: __("Item Name"), + options: options, + reqd: 1, + onchange: function () { + let field = d.get_field("scheduled_date"); + me.frm.call('get_pending_data', + { + item_name: this.value, + data_type: "date" + }).then(r => { + field.df.options = r.message; + field.refresh(); + }); + } + }, + { + label: __('Scheduled Date'), + fieldname: 'scheduled_date', + fieldtype: 'Select', + options: "", + reqd: 1, + onchange: function () { + let field = d.get_field('item_name'); + me.frm.call( + 'get_pending_data', + { + item_name: field.value, + s_date: this.value, + data_type: "id" + }).then(r => { + schedule_id = r.message; + }); + } + }, + ], + primary_action_label: 'Create Visit', + primary_action(values) { + frappe.call({ + method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.make_maintenance_visit", + args: { + item_name: values.item_name, + s_id: schedule_id, + source_name: me.frm.doc.name, + }, + callback: function (r) { + if (!r.exc) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + } + }); + d.hide(); + } + }); + d.show(); }); - }, __('Create')); + }, __('Create')); + } } } @@ -92,25 +154,10 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap set_no_of_visits(doc, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); - - if (item.start_date && item.end_date && item.periodicity) { - if(item.start_date > item.end_date) { - frappe.msgprint(__("Row {0}:Start Date must be before End Date", [item.idx])); - return; - } - - var date_diff = frappe.datetime.get_diff(item.end_date, item.start_date) + 1; - - var days_in_period = { - "Weekly": 7, - "Monthly": 30, - "Quarterly": 91, - "Half Yearly": 182, - "Yearly": 365 - } - - var no_of_visits = cint(date_diff / days_in_period[item.periodicity]); - frappe.model.set_value(item.doctype, item.name, "no_of_visits", no_of_visits); + let me = this; + if (item.start_date && item.periodicity) { + me.frm.call('validate_end_date_visits'); + } } }; diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.json b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.json index 606d22f52b7..4f89a679c82 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.json +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.json @@ -1,852 +1,264 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "naming_series:", - "beta": 0, - "creation": "2013-01-10 16:34:30", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 0, + "actions": [], + "autoname": "naming_series:", + "creation": "2013-01-10 16:34:30", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "customer_details", + "naming_series", + "customer", + "column_break0", + "status", + "transaction_date", + "items_section", + "items", + "schedule", + "generate_schedule", + "schedules", + "contact_info", + "customer_name", + "contact_person", + "contact_mobile", + "contact_email", + "contact_display", + "column_break_17", + "customer_address", + "address_display", + "territory", + "customer_group", + "company", + "amended_from" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "customer_details", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-user", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "customer_details", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break", + "options": "fa fa-user" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "naming_series", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Series", - "length": 0, - "no_copy": 1, - "options": "MAT-MSH-.YYYY.-", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 1, - "translatable": 0, - "unique": 0 - }, + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "MAT-MSH-.YYYY.-", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "customer", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Customer", - "length": 0, - "no_copy": 0, - "oldfieldname": "customer", - "oldfieldtype": "Link", - "options": "Customer", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "customer", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Customer", + "oldfieldname": "customer", + "oldfieldtype": "Link", + "options": "Customer", + "print_hide": 1, + "search_index": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break0", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "oldfieldtype": "Column Break", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break0", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Draft", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Status", - "length": 0, - "no_copy": 1, - "oldfieldname": "status", - "oldfieldtype": "Select", - "options": "\nDraft\nSubmitted\nCancelled", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "oldfieldname": "status", + "oldfieldtype": "Select", + "options": "\nDraft\nSubmitted\nCancelled", + "read_only": 1, + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "transaction_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Transaction Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "transaction_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "transaction_date", + "fieldtype": "Date", + "label": "Transaction Date", + "oldfieldname": "transaction_date", + "oldfieldtype": "Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "items_section", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-shopping-cart", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "items_section", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break", + "options": "fa fa-shopping-cart" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "items", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Items", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_maintenance_detail", - "oldfieldtype": "Table", - "options": "Maintenance Schedule Item", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "oldfieldname": "item_maintenance_detail", + "oldfieldtype": "Table", + "options": "Maintenance Schedule Item", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "schedule", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Schedule", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-time", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "schedule", + "fieldtype": "Section Break", + "label": "Schedule", + "oldfieldtype": "Section Break", + "options": "fa fa-time" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "generate_schedule", - "fieldtype": "Button", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Generate Schedule", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Button", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "generate_schedule", + "fieldtype": "Button", + "label": "Generate Schedule", + "oldfieldtype": "Button" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "schedules", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Schedules", - "length": 0, - "no_copy": 0, - "oldfieldname": "schedules", - "oldfieldtype": "Table", - "options": "Maintenance Schedule Detail", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "schedules", + "fieldtype": "Table", + "label": "Schedules", + "oldfieldname": "schedules", + "oldfieldtype": "Table", + "options": "Maintenance Schedule Detail" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "contact_info", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact Info", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "contact_info", + "fieldtype": "Section Break", + "label": "Contact Info" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 1, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "fieldname": "customer_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Customer Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "customer_name", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "bold": 1, + "depends_on": "customer", + "fieldname": "customer_name", + "fieldtype": "Data", + "in_global_search": 1, + "in_list_view": 1, + "label": "Customer Name", + "oldfieldname": "customer_name", + "oldfieldtype": "Data", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "fieldname": "contact_person", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact Person", - "length": 0, - "no_copy": 0, - "options": "Contact", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "contact_person", + "fieldtype": "Link", + "label": "Contact Person", + "options": "Contact", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "fieldname": "contact_mobile", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Mobile No", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "contact_mobile", + "fieldtype": "Data", + "hidden": 1, + "label": "Mobile No", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "fieldname": "contact_email", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact Email", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "contact_email", + "fieldtype": "Data", + "hidden": 1, + "label": "Contact Email", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "contact_display", - "fieldtype": "Small Text", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "contact_display", + "fieldtype": "Small Text", + "hidden": 1, + "in_global_search": 1, + "label": "Contact", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_17", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "fieldname": "customer_address", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Address", - "length": 0, - "no_copy": 0, - "options": "Address", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "customer_address", + "fieldtype": "Link", + "label": "Customer Address", + "options": "Address", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "address_display", - "fieldtype": "Small Text", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Address", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "address_display", + "fieldtype": "Small Text", + "hidden": 1, + "label": "Address", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "description": "", - "fieldname": "territory", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Territory", - "length": 0, - "no_copy": 0, - "oldfieldname": "territory", - "oldfieldtype": "Link", - "options": "Territory", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "territory", + "fieldtype": "Link", + "label": "Territory", + "oldfieldname": "territory", + "oldfieldtype": "Link", + "options": "Territory" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "description": "", - "fieldname": "customer_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Group", - "length": 0, - "no_copy": 0, - "options": "Customer Group", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Company", - "length": 0, - "no_copy": 0, - "oldfieldname": "company", - "oldfieldtype": "Link", - "options": "Company", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 1, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "oldfieldname": "company", + "oldfieldtype": "Link", + "options": "Company", + "remember_last_selected_value": 1, + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Amended From", - "length": 0, - "no_copy": 1, - "options": "Maintenance Schedule", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Maintenance Schedule", + "print_hide": 1, + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-calendar", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "Maintenance", - "name": "Maintenance Schedule", - "owner": "Administrator", + ], + "icon": "fa fa-calendar", + "idx": 1, + "is_submittable": 1, + "links": [ + { + "group": "Visits", + "link_doctype": "Maintenance Visit", + "link_fieldname": "maintenance_schedule" + } + ], + "modified": "2021-05-27 16:05:10.746465", + "modified_by": "Administrator", + "module": "Maintenance", + "name": "Maintenance Schedule", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Maintenance Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Maintenance Manager", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "search_fields": "status,customer,customer_name", - "show_name_in_global_search": 0, - "sort_order": "DESC", - "timeline_field": "customer", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "search_fields": "status,customer,customer_name", + "sort_field": "modified", + "sort_order": "DESC", + "timeline_field": "customer" } \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py index 0aefe19c8d8..d6e42f3ee1c 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py @@ -4,12 +4,13 @@ from __future__ import unicode_literals import frappe -from frappe.utils import add_days, getdate, cint, cstr +from frappe.utils import add_days, getdate, cint, cstr, date_diff, formatdate from frappe import throw, _ from erpnext.utilities.transaction_base import TransactionBase, delete_events from erpnext.stock.utils import get_valid_serial_nos from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos class MaintenanceSchedule(TransactionBase): @frappe.whitelist() @@ -32,8 +33,40 @@ class MaintenanceSchedule(TransactionBase): child.idx = count count = count + 1 child.sales_person = d.sales_person + child.completion_status = "Pending" + child.item_reference = d.name + + @frappe.whitelist() + def validate_end_date_visits(self): + days_in_period = { + "Weekly": 7, + "Monthly": 30, + "Quarterly": 91, + "Half Yearly": 182, + "Yearly": 365 + } + for item in self.items: + if item.periodicity and item.start_date: + if not item.end_date: + if item.no_of_visits: + item.end_date = add_days(item.start_date, item.no_of_visits * days_in_period[item.periodicity]) + else: + item.end_date = add_days(item.start_date, days_in_period[item.periodicity]) + + diff = date_diff(item.end_date, item.start_date) + 1 + no_of_visits = cint(diff / days_in_period[item.periodicity]) + + if not item.no_of_visits or item.no_of_visits == 0: + item.end_date = add_days(item.start_date, days_in_period[item.periodicity]) + diff = date_diff(item.end_date, item.start_date) + 1 + item.no_of_visits = cint(diff / days_in_period[item.periodicity]) + + elif item.no_of_visits > no_of_visits: + item.end_date = add_days(item.start_date, item.no_of_visits * days_in_period[item.periodicity]) + + elif item.no_of_visits < no_of_visits: + item.end_date = add_days(item.start_date, item.no_of_visits * days_in_period[item.periodicity]) - self.save() def on_submit(self): if not self.get('schedules'): @@ -58,9 +91,10 @@ class MaintenanceSchedule(TransactionBase): if no_email_sp: frappe.msgprint( - frappe._("Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}").format( + _("Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}").format( self.owner, "
    " + "
    ".join(no_email_sp) - )) + ) + ) scheduled_date = frappe.db.sql("""select scheduled_date from `tabMaintenance Schedule Detail` where sales_person=%s and item_code=%s and @@ -106,7 +140,7 @@ class MaintenanceSchedule(TransactionBase): if employee: holiday_list = get_holiday_list_for_employee(employee) else: - holiday_list = frappe.get_cached_value('Company', self.company, "default_holiday_list") + holiday_list = frappe.get_cached_value('Company', self.company, "default_holiday_list") holidays = frappe.db.sql_list('''select holiday_date from `tabHoliday` where parent=%s''', holiday_list) @@ -135,8 +169,7 @@ class MaintenanceSchedule(TransactionBase): } if date_diff < days_in_period[d.periodicity]: - throw(_("Row {0}: To set {1} periodicity, difference between from and to date \ - must be greater than or equal to {2}") + throw(_("Row {0}: To set {1} periodicity, difference between from and to date must be greater than or equal to {2}") .format(d.idx, d.periodicity, days_in_period[d.periodicity])) def validate_maintenance_detail(self): @@ -166,13 +199,15 @@ class MaintenanceSchedule(TransactionBase): throw(_("Maintenance Schedule {0} exists against {1}").format(chk[0][0], d.sales_order)) def validate(self): + self.validate_end_date_visits() self.validate_maintenance_detail() self.validate_dates_with_periodicity() self.validate_sales_order() + self.generate_schedule() def on_update(self): frappe.db.set(self, 'status', 'Draft') - + def update_amc_date(self, serial_nos, amc_expiry_date=None): for serial_no in serial_nos: serial_no_doc = frappe.get_doc("Serial No", serial_no) @@ -202,8 +237,8 @@ class MaintenanceSchedule(TransactionBase): if not sr_details.warehouse and sr_details.delivery_date and \ getdate(sr_details.delivery_date) >= getdate(amc_start_date): - throw(_("Maintenance start date can not be before delivery date for Serial No {0}") - .format(serial_no)) + throw(_("Maintenance start date can not be before delivery date for Serial No {0}") + .format(serial_no)) def validate_schedule(self): item_lst1 =[] @@ -245,13 +280,50 @@ class MaintenanceSchedule(TransactionBase): def on_trash(self): delete_events(self.doctype, self.name) + @frappe.whitelist() + def get_pending_data(self, data_type, s_date=None, item_name=None): + if data_type == "date": + dates = "" + for schedule in self.schedules: + if schedule.item_name == item_name and schedule.completion_status == "Pending": + dates = dates + "\n" + formatdate(schedule.scheduled_date, "dd-MM-yyyy") + return dates + elif data_type == "items": + items = "" + for item in self.items: + for schedule in self.schedules: + if item.item_name == schedule.item_name and schedule.completion_status == "Pending": + items = items + "\n" + item.item_name + break + return items + elif data_type == "id": + for schedule in self.schedules: + if schedule.item_name == item_name and s_date == formatdate(schedule.scheduled_date, "dd-mm-yyyy"): + return schedule.name + @frappe.whitelist() -def make_maintenance_visit(source_name, target_doc=None): +def update_serial_nos(s_id): + serial_nos = frappe.db.get_value('Maintenance Schedule Detail', s_id, 'serial_no') + if serial_nos: + serial_nos = get_serial_nos(serial_nos) + return serial_nos + else: + return False + +@frappe.whitelist() +def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=None): from frappe.model.mapper import get_mapped_doc - def update_status(source, target, parent): + def update_status_and_detail(source, target, parent): target.maintenance_type = "Scheduled" - + target.maintenance_schedule = source.name + target.maintenance_schedule_detail = s_id + + def update_sales(source, target, parent): + sales_person = frappe.db.get_value('Maintenance Schedule Detail', s_id, 'sales_person') + target.service_person = sales_person + target.serial_no = '' + doclist = get_mapped_doc("Maintenance Schedule", source_name, { "Maintenance Schedule": { "doctype": "Maintenance Visit", @@ -261,15 +333,12 @@ def make_maintenance_visit(source_name, target_doc=None): "validation": { "docstatus": ["=", 1] }, - "postprocess": update_status + "postprocess": update_status_and_detail }, "Maintenance Schedule Item": { "doctype": "Maintenance Visit Purpose", - "field_map": { - "parent": "prevdoc_docname", - "parenttype": "prevdoc_doctype", - "sales_person": "service_person" - } + "condition": lambda doc: doc.item_name == item_name, + "postprocess": update_sales } }, target_doc) diff --git a/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py index 3c307e920fc..09981bad05f 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py +++ b/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py @@ -2,7 +2,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt from __future__ import unicode_literals -from frappe.utils.data import get_datetime, add_days +from frappe.utils.data import add_days, today, formatdate +from erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule import make_maintenance_visit import frappe import unittest @@ -21,7 +22,57 @@ class TestMaintenanceSchedule(unittest.TestCase): ms.cancel() events_after_cancel = get_events(ms) self.assertTrue(len(events_after_cancel) == 0) + + def test_make_schedule(self): + ms = make_maintenance_schedule() + ms.save() + i = ms.items[0] + expected_dates = [] + expected_end_date = add_days(i.start_date, i.no_of_visits * 7) + self.assertEqual(i.end_date, expected_end_date) + i.no_of_visits = 2 + ms.save() + expected_end_date = add_days(i.start_date, i.no_of_visits * 7) + self.assertEqual(i.end_date, expected_end_date) + + items = ms.get_pending_data(data_type = "items") + items = items.split('\n') + items.pop(0) + expected_items = ['_Test Item'] + self.assertTrue(items, expected_items) + + # "dates" contains all generated schedule dates + dates = ms.get_pending_data(data_type = "date", item_name = i.item_name) + dates = dates.split('\n') + dates.pop(0) + expected_dates.append(formatdate(add_days(i.start_date, 7), "dd-MM-yyyy")) + expected_dates.append(formatdate(add_days(i.start_date, 14), "dd-MM-yyyy")) + + # test for generated schedule dates + self.assertEqual(dates, expected_dates) + + ms.submit() + s_id = ms.get_pending_data(data_type = "id", item_name = i.item_name, s_date = expected_dates[1]) + test = make_maintenance_visit(source_name = ms.name, item_name = "_Test Item", s_id = s_id) + visit = frappe.new_doc('Maintenance Visit') + visit = test + visit.maintenance_schedule = ms.name + visit.maintenance_schedule_detail = s_id + visit.completion_status = "Partially Completed" + visit.set('purposes', [{ + 'item_code': i.item_code, + 'description': "test", + 'work_done': "test", + 'service_person': "Sales Team", + }]) + visit.save() + visit.submit() + ms = frappe.get_doc('Maintenance Schedule', ms.name) + + #checks if visit status is back updated in schedule + self.assertTrue(ms.schedules[1].completion_status, "Partially Completed") + def get_events(ms): return frappe.get_all("Event Participants", filters={ "reference_doctype": ms.doctype, @@ -33,12 +84,11 @@ def make_maintenance_schedule(): ms = frappe.new_doc("Maintenance Schedule") ms.company = "_Test Company" ms.customer = "_Test Customer" - ms.transaction_date = get_datetime() + ms.transaction_date = today() ms.append("items", { "item_code": "_Test Item", - "start_date": get_datetime(), - "end_date": add_days(get_datetime(), 32), + "start_date": today(), "periodicity": "Weekly", "no_of_visits": 4, "sales_person": "Sales Team", diff --git a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json index 7cd30861556..8ccef6a8172 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json @@ -1,222 +1,137 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "hash", - "beta": 0, - "creation": "2013-02-22 01:28:05", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "hash", + "creation": "2013-02-22 01:28:05", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "column_break_3", + "scheduled_date", + "actual_date", + "section_break_6", + "sales_person", + "column_break_8", + "completion_status", + "section_break_10", + "serial_no", + "item_reference" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_code", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Code", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_code", - "oldfieldtype": "Link", - "options": "Item", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 1, - "set_only_once": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "oldfieldname": "item_code", + "oldfieldtype": "Link", + "options": "Item", + "read_only": 1, + "search_index": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_name", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Item Name", + "oldfieldname": "item_name", + "oldfieldtype": "Data", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "scheduled_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Scheduled Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "scheduled_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "scheduled_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Scheduled Date", + "oldfieldname": "scheduled_date", + "oldfieldtype": "Date", + "reqd": 1, + "search_index": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "actual_date", - "fieldtype": "Date", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Actual Date", - "length": 0, - "no_copy": 1, - "oldfieldname": "actual_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 1, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "actual_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Actual Date", + "no_copy": 1, + "oldfieldname": "actual_date", + "oldfieldtype": "Date", + "print_hide": 1, + "read_only": 1, + "report_hide": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "sales_person", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Sales Person", - "length": 0, - "no_copy": 0, - "oldfieldname": "incharge_name", - "oldfieldtype": "Link", - "options": "Sales Person", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "columns": 2, + "fieldname": "sales_person", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sales Person", + "oldfieldname": "incharge_name", + "oldfieldtype": "Link", + "options": "Sales Person", + "read_only_depends_on": "eval:doc.completion_status != \"Pending\"" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "serial_no", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Serial No", - "length": 0, - "no_copy": 0, - "oldfieldname": "serial_no", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "160px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "fieldname": "serial_no", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Serial No", + "oldfieldname": "serial_no", + "oldfieldtype": "Small Text", + "print_width": "160px", + "read_only": 1, "width": "160px" + }, + { + "columns": 2, + "fieldname": "completion_status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Completion Status", + "options": "Pending\nPartially Completed\nFully Completed", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_10", + "fieldtype": "Section Break" + }, + { + "fieldname": "item_reference", + "fieldtype": "Link", + "hidden": 1, + "label": "Item Reference", + "options": "Maintenance Schedule Item", + "read_only": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2017-02-17 17:05:44.644663", - "modified_by": "Administrator", - "module": "Maintenance", - "name": "Maintenance Schedule Detail", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "track_changes": 1, - "track_seen": 0 + ], + "idx": 1, + "istable": 1, + "links": [], + "modified": "2021-05-27 16:07:25.905015", + "modified_by": "Administrator", + "module": "Maintenance", + "name": "Maintenance Schedule Detail", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json index b371dfc4f50..3dacdead62c 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json @@ -1,431 +1,160 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "hash", - "beta": 0, - "creation": "2013-02-22 01:28:05", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "hash", + "creation": "2013-02-22 01:28:05", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "description", + "column_break_4", + "start_date", + "end_date", + "periodicity", + "schedule_details", + "no_of_visits", + "column_break_10", + "sales_person", + "reference", + "serial_no", + "sales_order" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_code", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Code", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_code", - "oldfieldtype": "Link", - "options": "Item", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "oldfieldname": "item_code", + "oldfieldtype": "Link", + "options": "Item", + "reqd": 1, + "search_index": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "columns": 1, "fetch_from": "item_code.item_name", - "fieldname": "item_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_name", - "oldfieldtype": "Data", - "options": "", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "in_list_view": 1, + "label": "Item Name", + "oldfieldname": "item_name", + "oldfieldtype": "Data", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fetch_from": "item_code.description", - "fieldname": "description", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "oldfieldname": "description", - "oldfieldtype": "Data", - "options": "", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "300px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Data", + "print_width": "300px", + "read_only": 1, "width": "300px" - }, + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "schedule_details", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "schedule_details", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "start_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Start Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "start_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "start_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Start Date", + "oldfieldname": "start_date", + "oldfieldtype": "Date", + "reqd": 1, + "search_index": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "end_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "End Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "end_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date", + "oldfieldname": "end_date", + "oldfieldtype": "Date", + "reqd": 1, + "search_index": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "periodicity", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Periodicity", - "length": 0, - "no_copy": 0, - "oldfieldname": "periodicity", - "oldfieldtype": "Select", - "options": "\nWeekly\nMonthly\nQuarterly\nHalf Yearly\nYearly\nRandom", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "columns": 1, + "fieldname": "periodicity", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Periodicity", + "oldfieldname": "periodicity", + "oldfieldtype": "Select", + "options": "\nWeekly\nMonthly\nQuarterly\nHalf Yearly\nYearly\nRandom" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "no_of_visits", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "No of Visits", - "length": 0, - "no_copy": 0, - "oldfieldname": "no_of_visits", - "oldfieldtype": "Int", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "columns": 1, + "fieldname": "no_of_visits", + "fieldtype": "Int", + "in_list_view": 1, + "label": "No of Visits", + "oldfieldname": "no_of_visits", + "oldfieldtype": "Int", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "sales_person", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Sales Person", - "length": 0, - "no_copy": 0, - "oldfieldname": "incharge_name", - "oldfieldtype": "Link", - "options": "Sales Person", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "sales_person", + "fieldtype": "Link", + "label": "Sales Person", + "oldfieldname": "incharge_name", + "oldfieldtype": "Link", + "options": "Sales Person" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reference", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Reference", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "reference", + "fieldtype": "Section Break", + "label": "Reference" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "serial_no", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Serial No", - "length": 0, - "no_copy": 0, - "oldfieldname": "serial_no", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial No", + "oldfieldname": "serial_no", + "oldfieldtype": "Small Text" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "sales_order", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Sales Order", - "length": 0, - "no_copy": 1, - "oldfieldname": "prevdoc_docname", - "oldfieldtype": "Data", - "options": "Sales Order", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "print_width": "150px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "no_copy": 1, + "oldfieldname": "prevdoc_docname", + "oldfieldtype": "Data", + "options": "Sales Order", + "print_hide": 1, + "print_width": "150px", + "read_only": 1, + "search_index": 1, "width": "150px" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2018-05-16 22:43:14.260729", - "modified_by": "Administrator", - "module": "Maintenance", - "name": "Maintenance Schedule Item", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "track_changes": 0, - "track_seen": 0 + ], + "idx": 1, + "istable": 1, + "links": [], + "modified": "2021-04-15 16:09:47.311994", + "modified_by": "Administrator", + "module": "Maintenance", + "name": "Maintenance Schedule Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js index 503253040b2..53ecdf5a61f 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js @@ -2,39 +2,62 @@ // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.maintenance"); - +var serial_nos = []; frappe.ui.form.on('Maintenance Visit', { - refresh: function(frm) { + refresh: function (frm) { //filters for serial_no based on item_code - frm.set_query('serial_no', 'purposes', function(frm, cdt, cdn) { + frm.set_query('serial_no', 'purposes', function (frm, cdt, cdn) { let item = locals[cdt][cdn]; - return { - filters: { - 'item_code': item.item_code - } - }; + if (serial_nos) { + return { + filters: { + 'item_code': item.item_code, + 'name': ["in", serial_nos] + } + }; + } else { + return { + filters: { + 'item_code': item.item_code + } + }; + } }); }, - setup: function(frm) { + setup: function (frm) { frm.set_query('contact_person', erpnext.queries.contact_query); frm.set_query('customer_address', erpnext.queries.address_query); frm.set_query('customer', erpnext.queries.customer); }, - onload: function(frm) { + onload: function (frm, cdt, cdn) { + let item = locals[cdt][cdn]; + if (frm.maintenance_type == 'Scheduled') { + let schedule_id = item.purposes[0].prevdoc_detail_docname; + frappe.call({ + method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.update_serial_nos", + args: { + s_id: schedule_id + }, + callback: function (r) { + serial_nos = r.message; + } + }); + } + if (!frm.doc.status) { - frm.set_value({status:'Draft'}); + frm.set_value({ status: 'Draft' }); } if (frm.doc.__islocal) { - frm.set_value({mntc_date: frappe.datetime.get_today()}); + frm.set_value({ mntc_date: frappe.datetime.get_today() }); } }, - customer: function(frm) { + customer: function (frm) { erpnext.utils.get_party_details(frm); }, - customer_address: function(frm) { + customer_address: function (frm) { erpnext.utils.get_address_display(frm, 'customer_address', 'address_display'); }, - contact_person: function(frm) { + contact_person: function (frm) { erpnext.utils.get_contact_details(frm); } @@ -47,9 +70,9 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui. var me = this; - if (this.frm.doc.docstatus===0) { + if (this.frm.doc.docstatus === 0) { this.frm.add_custom_button(__('Maintenance Schedule'), - function() { + function () { erpnext.utils.map_current_doc({ method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.make_maintenance_visit", source_doctype: "Maintenance Schedule", @@ -64,7 +87,7 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui. }) }, __("Get Items From")); this.frm.add_custom_button(__('Warranty Claim'), - function() { + function () { erpnext.utils.map_current_doc({ method: "erpnext.support.doctype.warranty_claim.warranty_claim.make_maintenance_visit", source_doctype: "Warranty Claim", @@ -80,7 +103,7 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui. }) }, __("Get Items From")); this.frm.add_custom_button(__('Sales Order'), - function() { + function () { erpnext.utils.map_current_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_visit", source_doctype: "Sales Order", diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json index 32bfa0e324b..ec32239518f 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.json @@ -1,1042 +1,324 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "naming_series:", - "beta": 0, - "creation": "2013-01-10 16:34:31", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 0, + "actions": [], + "autoname": "naming_series:", + "creation": "2013-01-10 16:34:31", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "customer_details", + "column_break0", + "naming_series", + "customer", + "customer_name", + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "maintenance_schedule", + "maintenance_schedule_detail", + "column_break1", + "mntc_date", + "mntc_time", + "maintenance_details", + "completion_status", + "column_break_14", + "maintenance_type", + "section_break0", + "purposes", + "more_info", + "customer_feedback", + "col_break3", + "status", + "amended_from", + "company", + "contact_info_section", + "customer_address", + "contact_person", + "col_break4", + "territory", + "customer_group" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "customer_details", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-user", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "customer_details", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break", + "options": "fa fa-user" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break0", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "oldfieldtype": "Column Break", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break0", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "naming_series", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Series", - "length": 0, - "no_copy": 1, - "options": "MAT-MVS-.YYYY.-", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 1, - "translatable": 0, - "unique": 0 - }, + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "MAT-MVS-.YYYY.-", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "customer", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer", - "length": 0, - "no_copy": 0, - "oldfieldname": "customer", - "oldfieldtype": "Link", - "options": "Customer", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "customer", + "fieldtype": "Link", + "in_global_search": 1, + "label": "Customer", + "oldfieldname": "customer", + "oldfieldtype": "Link", + "options": "Customer", + "print_hide": 1, + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 1, - "collapsible": 0, - "columns": 0, - "fieldname": "customer_name", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "bold": 1, + "fieldname": "customer_name", + "fieldtype": "Data", + "hidden": 1, + "in_global_search": 1, + "label": "Customer Name", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "address_display", - "fieldtype": "Small Text", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Address", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "address_display", + "fieldtype": "Small Text", + "hidden": 1, + "label": "Address", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "contact_display", - "fieldtype": "Small Text", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "contact_display", + "fieldtype": "Small Text", + "hidden": 1, + "in_global_search": 1, + "label": "Contact", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "contact_mobile", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Mobile No", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "contact_mobile", + "fieldtype": "Data", + "hidden": 1, + "label": "Mobile No", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "contact_email", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact Email", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "contact_email", + "fieldtype": "Data", + "hidden": 1, + "label": "Contact Email", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break1", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "oldfieldtype": "Column Break", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "column_break1", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", "width": "50%" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Today", - "fieldname": "mntc_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Maintenance Date", - "length": 0, - "no_copy": 1, - "oldfieldname": "mntc_date", - "oldfieldtype": "Date", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Today", + "fieldname": "mntc_date", + "fieldtype": "Date", + "label": "Maintenance Date", + "no_copy": 1, + "oldfieldname": "mntc_date", + "oldfieldtype": "Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mntc_time", - "fieldtype": "Time", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Maintenance Time", - "length": 0, - "no_copy": 1, - "oldfieldname": "mntc_time", - "oldfieldtype": "Time", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "mntc_time", + "fieldtype": "Time", + "label": "Maintenance Time", + "no_copy": 1, + "oldfieldname": "mntc_time", + "oldfieldtype": "Time" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "maintenance_details", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-wrench", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "maintenance_details", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break", + "options": "fa fa-wrench" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "completion_status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Completion Status", - "length": 0, - "no_copy": 0, - "oldfieldname": "completion_status", - "oldfieldtype": "Select", - "options": "\nPartially Completed\nFully Completed", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "completion_status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Completion Status", + "oldfieldname": "completion_status", + "oldfieldtype": "Select", + "options": "\nPartially Completed\nFully Completed", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_14", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Unscheduled", - "fieldname": "maintenance_type", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Maintenance Type", - "length": 0, - "no_copy": 0, - "oldfieldname": "maintenance_type", - "oldfieldtype": "Select", - "options": "\nScheduled\nUnscheduled\nBreakdown", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Unscheduled", + "fieldname": "maintenance_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Maintenance Type", + "oldfieldname": "maintenance_type", + "oldfieldtype": "Select", + "options": "\nScheduled\nUnscheduled\nBreakdown", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break0", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-wrench", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "section_break0", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break", + "options": "fa fa-wrench" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "purposes", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Purposes", - "length": 0, - "no_copy": 0, - "oldfieldname": "maintenance_visit_details", - "oldfieldtype": "Table", - "options": "Maintenance Visit Purpose", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "purposes", + "fieldtype": "Table", + "label": "Purposes", + "oldfieldname": "maintenance_visit_details", + "oldfieldtype": "Table", + "options": "Maintenance Visit Purpose", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "more_info", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "More Information", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "fa fa-file-text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "more_info", + "fieldtype": "Section Break", + "label": "More Information", + "oldfieldtype": "Section Break", + "options": "fa fa-file-text" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "customer_feedback", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Feedback", - "length": 0, - "no_copy": 0, - "oldfieldname": "customer_feedback", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "customer_feedback", + "fieldtype": "Small Text", + "label": "Customer Feedback", + "oldfieldname": "customer_feedback", + "oldfieldtype": "Small Text" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "col_break3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "col_break3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Draft", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Status", - "length": 0, - "no_copy": 1, - "oldfieldname": "status", - "oldfieldtype": "Data", - "options": "\nDraft\nCancelled\nSubmitted", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "no_copy": 1, + "oldfieldname": "status", + "oldfieldtype": "Data", + "options": "\nDraft\nCancelled\nSubmitted", + "read_only": 1, + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 1, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Amended From", - "length": 0, - "no_copy": 1, - "oldfieldname": "amended_from", - "oldfieldtype": "Data", - "options": "Maintenance Visit", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "amended_from", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Amended From", + "no_copy": 1, + "oldfieldname": "amended_from", + "oldfieldtype": "Data", + "options": "Maintenance Visit", + "print_hide": 1, + "read_only": 1, "width": "150px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Company", - "length": 0, - "no_copy": 0, - "oldfieldname": "company", - "oldfieldtype": "Select", - "options": "Company", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 1, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "oldfieldname": "company", + "oldfieldtype": "Select", + "options": "Company", + "print_hide": 1, + "remember_last_selected_value": 1, + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "customer", - "fieldname": "contact_info_section", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact Info", - "length": 0, - "no_copy": 0, - "options": "fa fa-bullhorn", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "customer", + "fieldname": "contact_info_section", + "fieldtype": "Section Break", + "label": "Contact Info", + "options": "fa fa-bullhorn" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "customer_address", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Address", - "length": 0, - "no_copy": 0, - "options": "Address", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "customer_address", + "fieldtype": "Link", + "label": "Customer Address", + "options": "Address", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "contact_person", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Contact Person", - "length": 0, - "no_copy": 0, - "options": "Contact", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "contact_person", + "fieldtype": "Link", + "label": "Contact Person", + "options": "Contact", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "col_break4", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "col_break4", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "territory", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Territory", - "length": 0, - "no_copy": 0, - "options": "Territory", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "territory", + "fieldtype": "Link", + "label": "Territory", + "options": "Territory", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "customer_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Group", - "length": 0, - "no_copy": 0, - "options": "Customer Group", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group", + "print_hide": 1 + }, + { + "fieldname": "maintenance_schedule", + "fieldtype": "Link", + "label": "Maintenance Schedule", + "options": "Maintenance Schedule", + "read_only": 1 + }, + { + "fieldname": "maintenance_schedule_detail", + "fieldtype": "Link", + "hidden": 1, + "label": "Maintenance Schedule Detail", + "options": "Maintenance Schedule Detail" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-file-text", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "Maintenance", - "name": "Maintenance Visit", - "owner": "Administrator", + ], + "icon": "fa fa-file-text", + "idx": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-05-27 16:06:17.352572", + "modified_by": "Administrator", + "module": "Maintenance", + "name": "Maintenance Visit", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Maintenance User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Maintenance User", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "search_fields": "status,maintenance_type,customer,customer_name,mntc_date,company", - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "timeline_field": "customer", - "title_field": "customer_name", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "search_fields": "status,maintenance_type,customer,customer_name,mntc_date,company", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "timeline_field": "customer", + "title_field": "customer_name" } \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py index 2f2ad00e023..7fffc942a03 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import frappe from frappe import _ +from frappe.utils import get_datetime from erpnext.utilities.transaction_base import TransactionBase @@ -16,44 +17,62 @@ class MaintenanceVisit(TransactionBase): if d.serial_no and not frappe.db.exists("Serial No", d.serial_no): frappe.throw(_("Serial No {0} does not exist").format(d.serial_no)) + def validate_maintenance_date(self): + if self.maintenance_type == "Scheduled" and self.maintenance_schedule_detail: + item_ref = frappe.db.get_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'item_reference') + if item_ref: + start_date, end_date = frappe.db.get_value('Maintenance Schedule Item', item_ref, ['start_date', 'end_date']) + if get_datetime(self.mntc_date) < get_datetime(start_date) or get_datetime(self.mntc_date) > get_datetime(end_date): + frappe.throw(_("Date must be between {0} and {1}").format(start_date, end_date)) + def validate(self): self.validate_serial_no() + self.validate_maintenance_date() + + def update_completion_status(self): + if self.maintenance_schedule_detail: + frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'completion_status', self.completion_status) + + def update_actual_date(self): + if self.maintenance_schedule_detail: + frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'actual_date', self.mntc_date) def update_customer_issue(self, flag): - for d in self.get('purposes'): - if d.prevdoc_docname and d.prevdoc_doctype == 'Warranty Claim' : - if flag==1: - mntc_date = self.mntc_date - service_person = d.service_person - work_done = d.work_done - status = "Open" - if self.completion_status == 'Fully Completed': - status = 'Closed' - elif self.completion_status == 'Partially Completed': - status = 'Work In Progress' - else: - nm = frappe.db.sql("select t1.name, t1.mntc_date, t2.service_person, t2.work_done from `tabMaintenance Visit` t1, `tabMaintenance Visit Purpose` t2 where t2.parent = t1.name and t1.completion_status = 'Partially Completed' and t2.prevdoc_docname = %s and t1.name!=%s and t1.docstatus = 1 order by t1.name desc limit 1", (d.prevdoc_docname, self.name)) - - if nm: - status = 'Work In Progress' - mntc_date = nm and nm[0][1] or '' - service_person = nm and nm[0][2] or '' - work_done = nm and nm[0][3] or '' + if not self.maintenance_schedule: + for d in self.get('purposes'): + if d.prevdoc_docname and d.prevdoc_doctype == 'Warranty Claim' : + if flag==1: + mntc_date = self.mntc_date + service_person = d.service_person + work_done = d.work_done + status = "Open" + if self.completion_status == 'Fully Completed': + status = 'Closed' + elif self.completion_status == 'Partially Completed': + status = 'Work In Progress' else: - status = 'Open' - mntc_date = None - service_person = None - work_done = None + nm = frappe.db.sql("select t1.name, t1.mntc_date, t2.service_person, t2.work_done from `tabMaintenance Visit` t1, `tabMaintenance Visit Purpose` t2 where t2.parent = t1.name and t1.completion_status = 'Partially Completed' and t2.prevdoc_docname = %s and t1.name!=%s and t1.docstatus = 1 order by t1.name desc limit 1", (d.prevdoc_docname, self.name)) - wc_doc = frappe.get_doc('Warranty Claim', d.prevdoc_docname) - wc_doc.update({ - 'resolution_date': mntc_date, - 'resolved_by': service_person, - 'resolution_details': work_done, - 'status': status - }) + if nm: + status = 'Work In Progress' + mntc_date = nm and nm[0][1] or '' + service_person = nm and nm[0][2] or '' + work_done = nm and nm[0][3] or '' + else: + status = 'Open' + mntc_date = None + service_person = None + work_done = None - wc_doc.db_update() + wc_doc = frappe.get_doc('Warranty Claim', d.prevdoc_docname) + wc_doc.update({ + 'resolution_date': mntc_date, + 'resolved_by': service_person, + 'resolution_details': work_done, + 'status': status + }) + + wc_doc.db_update() def check_if_last_visit(self): """check if last maintenance visit against same sales order/ Warranty Claim""" @@ -77,6 +96,8 @@ class MaintenanceVisit(TransactionBase): def on_submit(self): self.update_customer_issue(1) frappe.db.set(self, 'status', 'Submitted') + self.update_completion_status() + self.update_actual_date() def on_cancel(self): self.check_if_last_visit() diff --git a/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json b/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json index 467441d841c..158f143ae86 100644 --- a/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json +++ b/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "hash", "creation": "2013-02-22 01:28:06", "doctype": "DocType", @@ -8,14 +9,15 @@ "field_order": [ "item_code", "item_name", + "column_break_3", + "service_person", "serial_no", + "section_break_6", "description", "work_details", - "service_person", "work_done", "prevdoc_doctype", - "prevdoc_docname", - "prevdoc_detail_docname" + "prevdoc_docname" ], "fields": [ { @@ -62,6 +64,8 @@ "fieldtype": "Section Break" }, { + "fetch_from": "prevdoc_detail_docname.sales_person", + "fetch_if_empty": 1, "fieldname": "service_person", "fieldtype": "Link", "in_list_view": 1, @@ -83,49 +87,30 @@ { "fieldname": "prevdoc_doctype", "fieldtype": "Link", + "hidden": 1, "label": "Document Type", - "no_copy": 1, - "oldfieldname": "prevdoc_doctype", - "oldfieldtype": "Data", - "options": "DocType", - "print_hide": 1, - "print_width": "150px", - "read_only": 1, - "report_hide": 1, - "width": "150px" + "options": "DocType" }, { "fieldname": "prevdoc_docname", "fieldtype": "Dynamic Link", + "hidden": 1, "label": "Against Document No", - "no_copy": 1, - "oldfieldname": "prevdoc_docname", - "oldfieldtype": "Data", - "options": "prevdoc_doctype", - "print_hide": 1, - "print_width": "160px", - "read_only": 1, - "report_hide": 1, - "width": "160px" + "options": "prevdoc_doctype" }, { - "fieldname": "prevdoc_detail_docname", - "fieldtype": "Data", - "hidden": 1, - "label": "Against Document Detail No", - "no_copy": 1, - "oldfieldname": "prevdoc_detail_docname", - "oldfieldtype": "Data", - "print_hide": 1, - "print_width": "160px", - "read_only": 1, - "report_hide": 1, - "width": "160px" + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" } ], "idx": 1, "istable": 1, - "modified": "2020-09-18 17:26:09.703215", + "links": [], + "modified": "2021-05-27 17:47:21.474282", "modified_by": "Administrator", "module": "Maintenance", "name": "Maintenance Visit Purpose", diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js index 4c31bd0b7d0..f19a1b08681 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js @@ -13,7 +13,7 @@ frappe.ui.form.on('Blanket Order', { refresh: function(frm) { erpnext.hide_company(); - if (frm.doc.customer && frm.doc.docstatus === 1) { + if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) { frm.add_custom_button(__("Sales Order"), function() { frappe.model.open_mapped_doc({ method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.make_order", diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.json b/erpnext/manufacturing/doctype/blanket_order/blanket_order.json index 0330e5c85c9..a63fc4da69a 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.json +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "naming_series:", "creation": "2018-05-24 07:18:08.256060", "doctype": "DocType", @@ -79,6 +80,7 @@ "reqd": 1 }, { + "allow_on_submit": 1, "fieldname": "to_date", "fieldtype": "Date", "label": "To Date", @@ -129,8 +131,10 @@ "label": "Terms and Conditions Details" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, - "modified": "2019-11-18 19:37:37.151686", + "links": [], + "modified": "2021-06-29 00:30:30.621636", "modified_by": "Administrator", "module": "Manufacturing", "name": "Blanket Order", diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 44f841f13b4..3f50b41be1b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -71,7 +71,6 @@ frappe.ui.form.on("BOM", { refresh: function(frm) { frm.toggle_enable("item", frm.doc.__islocal); - toggle_operations(frm); frm.set_indicator_formatter('item_code', function(doc) { @@ -84,7 +83,7 @@ frappe.ui.form.on("BOM", { if (!frm.doc.__islocal && frm.doc.docstatus<2) { frm.add_custom_button(__("Update Cost"), function() { - frm.events.update_cost(frm); + frm.events.update_cost(frm, true); }); frm.add_custom_button(__("Browse BOM"), function() { frappe.route_options = { @@ -319,15 +318,15 @@ frappe.ui.form.on("BOM", { }) }, - update_cost: function(frm) { + update_cost: function(frm, save_doc=false) { return frappe.call({ doc: frm.doc, method: "update_cost", freeze: true, args: { update_parent: true, - from_child_bom:false, - save: frm.doc.docstatus === 1 ? true : false + save: save_doc, + from_child_bom: false }, callback: function(r) { refresh_field("items"); @@ -651,15 +650,8 @@ frappe.ui.form.on("BOM Item", "items_remove", function(frm) { erpnext.bom.calculate_total(frm.doc); }); -var toggle_operations = function(frm) { - frm.toggle_display("operations_section", cint(frm.doc.with_operations) == 1); - frm.toggle_display("transfer_material_against", cint(frm.doc.with_operations) == 1); - frm.toggle_reqd("transfer_material_against", cint(frm.doc.with_operations) == 1); -}; - frappe.ui.form.on("BOM", "with_operations", function(frm) { if(!cint(frm.doc.with_operations)) { frm.set_value("operations", []); } - toggle_operations(frm); }); diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index f551b91597f..7e539183b0c 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -36,6 +36,9 @@ "materials_section", "inspection_required", "quality_inspection_template", + "column_break_31", + "bom_level", + "section_break_33", "items", "scrap_section", "scrap_items", @@ -193,6 +196,7 @@ }, { "default": "Work Order", + "depends_on": "with_operations", "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", @@ -235,6 +239,7 @@ { "fieldname": "operations_section", "fieldtype": "Section Break", + "hide_border": 1, "oldfieldtype": "Section Break" }, { @@ -245,6 +250,7 @@ "options": "Routing" }, { + "depends_on": "with_operations", "fieldname": "operations", "fieldtype": "Table", "label": "Operations", @@ -510,6 +516,22 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "column_break_31", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "bom_level", + "fieldtype": "Int", + "label": "BOM Level", + "read_only": 1 + }, + { + "fieldname": "section_break_33", + "fieldtype": "Section Break", + "hide_border": 1 } ], "icon": "fa fa-sitemap", @@ -517,7 +539,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2020-05-21 12:29:32.634952", + "modified": "2021-05-16 12:25:09.081968", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index d1f63854c71..bc092ef14f9 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1,7 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals +from typing import List +from collections import deque import frappe, erpnext from frappe.utils import cint, cstr, flt, today from frappe import _ @@ -16,14 +17,85 @@ from frappe.model.mapper import get_mapped_doc import functools -from six import string_types - from operator import itemgetter form_grid_templates = { "items": "templates/form_grid/item_grid.html" } + +class BOMTree: + """Full tree representation of a BOM""" + + # specifying the attributes to save resources + # ref: https://docs.python.org/3/reference/datamodel.html#slots + __slots__ = ["name", "child_items", "is_bom", "item_code", "exploded_qty", "qty"] + + def __init__(self, name: str, is_bom: bool = True, exploded_qty: float = 1.0, qty: float = 1) -> None: + self.name = name # name of node, BOM number if is_bom else item_code + self.child_items: List["BOMTree"] = [] # list of child items + self.is_bom = is_bom # true if the node is a BOM and not a leaf item + self.item_code: str = None # item_code associated with node + self.qty = qty # required unit quantity to make one unit of parent item. + self.exploded_qty = exploded_qty # total exploded qty required for making root of tree. + if not self.is_bom: + self.item_code = self.name + else: + self.__create_tree() + + def __create_tree(self): + bom = frappe.get_cached_doc("BOM", self.name) + self.item_code = bom.item + + for item in bom.get("items", []): + qty = item.qty / bom.quantity # quantity per unit + exploded_qty = self.exploded_qty * qty + if item.bom_no: + child = BOMTree(item.bom_no, exploded_qty=exploded_qty, qty=qty) + self.child_items.append(child) + else: + self.child_items.append( + BOMTree(item.item_code, is_bom=False, exploded_qty=exploded_qty, qty=qty) + ) + + def level_order_traversal(self) -> List["BOMTree"]: + """Get level order traversal of tree. + E.g. for following tree the traversal will return list of nodes in order from top to bottom. + BOM: + - SubAssy1 + - item1 + - item2 + - SubAssy2 + - item3 + - item4 + + returns = [SubAssy1, item1, item2, SubAssy2, item3, item4] + """ + traversal = [] + q = deque() + q.append(self) + + while q: + node = q.popleft() + + for child in node.child_items: + traversal.append(child) + q.append(child) + + return traversal + + def __str__(self) -> str: + return ( + f"{self.item_code}{' - ' + self.name if self.is_bom else ''} qty(per unit): {self.qty}" + f" exploded_qty: {self.exploded_qty}" + ) + + def __repr__(self, level: int = 0) -> str: + rep = "┃ " * (level - 1) + "┣━ " * (level > 0) + str(self) + "\n" + for child in self.child_items: + rep += child.__repr__(level=level + 1) + return rep + class BOM(WebsiteGenerator): website = frappe._dict( # page_title_field = "item_name", @@ -81,7 +153,8 @@ class BOM(WebsiteGenerator): self.validate_operations() self.calculate_cost() self.update_stock_qty() - self.update_cost(update_parent=False, from_child_bom=True, save=False) + self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate = False, save=False) + self.set_bom_level() def get_context(self, context): context.parents = [{'name': 'boms', 'title': _('All BOMs') }] @@ -152,7 +225,7 @@ class BOM(WebsiteGenerator): if not args: args = frappe.form_dict.get('args') - if isinstance(args, string_types): + if isinstance(args, str): import json args = json.loads(args) @@ -213,7 +286,7 @@ class BOM(WebsiteGenerator): return flt(rate) * flt(self.plc_conversion_rate or 1) / (self.conversion_rate or 1) @frappe.whitelist() - def update_cost(self, update_parent=True, from_child_bom=False, save=True): + def update_cost(self, update_parent=True, from_child_bom=False, update_hour_rate = True, save=True): if self.docstatus == 2: return @@ -242,7 +315,7 @@ class BOM(WebsiteGenerator): if self.docstatus == 1: self.flags.ignore_validate_update_after_submit = True - self.calculate_cost() + self.calculate_cost(update_hour_rate) if save: self.db_update() @@ -257,7 +330,7 @@ class BOM(WebsiteGenerator): frappe.get_doc("BOM", bom).update_cost(from_child_bom=True) if not from_child_bom: - frappe.msgprint(_("Cost Updated")) + frappe.msgprint(_("Cost Updated"), alert=True) def update_parent_cost(self): if self.total_cost: @@ -403,32 +476,47 @@ class BOM(WebsiteGenerator): bom_list.reverse() return bom_list - def calculate_cost(self): + def calculate_cost(self, update_hour_rate = False): """Calculate bom totals""" - self.calculate_op_cost() + self.calculate_op_cost(update_hour_rate) self.calculate_rm_cost() self.calculate_sm_cost() self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost self.base_total_cost = self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost - def calculate_op_cost(self): + def calculate_op_cost(self, update_hour_rate = False): """Update workstation rate and calculates totals""" self.operating_cost = 0 self.base_operating_cost = 0 for d in self.get('operations'): if d.workstation: - if not d.hour_rate: - hour_rate = flt(frappe.db.get_value("Workstation", d.workstation, "hour_rate")) - d.hour_rate = hour_rate / flt(self.conversion_rate) if self.conversion_rate else hour_rate - - if d.hour_rate and d.time_in_mins: - d.base_hour_rate = flt(d.hour_rate) * flt(self.conversion_rate) - d.operating_cost = flt(d.hour_rate) * flt(d.time_in_mins) / 60.0 - d.base_operating_cost = flt(d.operating_cost) * flt(self.conversion_rate) + self.update_rate_and_time(d, update_hour_rate) self.operating_cost += flt(d.operating_cost) self.base_operating_cost += flt(d.base_operating_cost) + def update_rate_and_time(self, row, update_hour_rate = False): + if not row.hour_rate or update_hour_rate: + hour_rate = flt(frappe.get_cached_value("Workstation", row.workstation, "hour_rate")) + row.hour_rate = (hour_rate / flt(self.conversion_rate) + if self.conversion_rate and hour_rate else hour_rate) + + if self.routing: + row.time_in_mins = flt(frappe.db.get_value("BOM Operation", { + "workstation": row.workstation, + "operation": row.operation, + "sequence_id": row.sequence_id, + "parent": self.routing + }, ["time_in_mins"])) + + if row.hour_rate and row.time_in_mins: + row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate) + row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0 + row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate) + + if update_hour_rate: + row.db_update() + def calculate_rm_cost(self): """Fetch RM rate as per today's valuation rate and calculate totals""" total_rm_cost = 0 @@ -575,7 +663,7 @@ class BOM(WebsiteGenerator): self.get_routing() def validate_operations(self): - if self.with_operations and not self.get('operations'): + if self.with_operations and not self.get('operations') and self.docstatus == 1: frappe.throw(_("Operations cannot be left blank")) if self.with_operations: @@ -585,6 +673,24 @@ class BOM(WebsiteGenerator): if not d.batch_size or d.batch_size <= 0: d.batch_size = 1 + def get_tree_representation(self) -> BOMTree: + """Get a complete tree representation preserving order of child items.""" + return BOMTree(self.name) + + def set_bom_level(self, update=False): + levels = [] + + self.bom_level = 0 + for row in self.items: + if row.bom_no: + levels.append(frappe.get_cached_value("BOM", row.bom_no, "bom_level") or 0) + + if levels: + self.bom_level = max(levels) + 1 + + if update: + self.db_set("bom_level", self.bom_level) + def get_bom_item_rate(args, bom_doc): if bom_doc.rm_cost_as_per == 'Valuation Rate': rate = get_valuation_rate(args) * (args.get("conversion_factor") or 1) @@ -607,7 +713,8 @@ def get_bom_item_rate(args, bom_doc): "conversion_rate": 1, # Passed conversion rate as 1 purposefully, as conversion rate is applied at the end of the function "conversion_factor": args.get("conversion_factor") or 1, "plc_conversion_rate": 1, - "ignore_party": True + "ignore_party": True, + "ignore_conversion_rate": True }) item_doc = frappe.get_cached_doc("Item", args.get("item_code")) out = frappe._dict() @@ -667,7 +774,7 @@ def get_bom_items_as_dict(bom, company, qty=1, fetch_exploded=1, fetch_scrap_ite item.image, bom.project, bom_item.rate, - bom_item.amount, + sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount, item.stock_uom, item.item_group, item.allow_alternative_item, @@ -768,7 +875,7 @@ def get_children(doctype, parent=None, is_root=False, **filters): frappe.form_dict.parent = parent if frappe.form_dict.parent: - bom_doc = frappe.get_doc("BOM", frappe.form_dict.parent) + bom_doc = frappe.get_cached_doc("BOM", frappe.form_dict.parent) frappe.has_permission("BOM", doc=bom_doc, throw=True) bom_items = frappe.get_all('BOM Item', @@ -779,7 +886,7 @@ def get_children(doctype, parent=None, is_root=False, **filters): item_names = tuple(d.get('item_code') for d in bom_items) items = frappe.get_list('Item', - fields=['image', 'description', 'name', 'stock_uom', 'item_name'], + fields=['image', 'description', 'name', 'stock_uom', 'item_name', 'is_sub_contracted_item'], filters=[['name', 'in', item_names]]) # to get only required item dicts for bom_item in bom_items: @@ -792,6 +899,7 @@ def get_children(doctype, parent=None, is_root=False, **filters): bom_item.parent_bom_qty = bom_doc.quantity bom_item.expandable = 0 if bom_item.value in ('', None) else 1 + bom_item.image = frappe.db.escape(bom_item.image) return bom_items @@ -975,7 +1083,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): if filters and filters.get("is_stock_item"): query_filters["is_stock_item"] = 1 - + return frappe.get_all("Item", fields = fields, filters=query_filters, or_filters = or_cond_filters, order_by=order_by, @@ -1008,6 +1116,8 @@ def make_variant_bom(source_name, bom_no, item, variant_items, target_doc=None): }, 'BOM Item': { 'doctype': 'BOM Item', + # stop get_mapped_doc copying parent bom_no to children + 'field_no_map': ['bom_no'], 'condition': lambda doc: doc.has_variants == 0 }, }, target_doc, postprocess) diff --git a/erpnext/manufacturing/doctype/bom/bom_item_preview.html b/erpnext/manufacturing/doctype/bom/bom_item_preview.html index 6cd5f8cb3cf..6088e46265b 100644 --- a/erpnext/manufacturing/doctype/bom/bom_item_preview.html +++ b/erpnext/manufacturing/doctype/bom/bom_item_preview.html @@ -1,13 +1,31 @@
    - {% if data.image %} - -
    - {% endif %} -

    - {{ __("Description") }} -

    -
    - {{ data.description }} +
    +
    + {% if data.image %} +
    + +
    + {% endif %} +
    +
    +

    + {{ __("Description") }} +

    +
    + {{ data.description }} +
    +
    +

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

    +

    diff --git a/erpnext/manufacturing/doctype/bom/bom_tree.js b/erpnext/manufacturing/doctype/bom/bom_tree.js index 185b9ed4bcf..60fb377f476 100644 --- a/erpnext/manufacturing/doctype/bom/bom_tree.js +++ b/erpnext/manufacturing/doctype/bom/bom_tree.js @@ -64,7 +64,7 @@ frappe.treeview_settings["BOM"] = { if(node.is_root && node.data.value!="BOM") { frappe.model.with_doc("BOM", node.data.value, function() { var bom = frappe.model.get_doc("BOM", node.data.value); - node.data.image = bom.image || ""; + node.data.image = escape(bom.image) || ""; node.data.description = bom.description || ""; }); } diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index e1cca9e3ef4..c89f7d66fdf 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -2,16 +2,17 @@ # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals +from collections import deque import unittest import frappe from frappe.utils import cstr, flt from frappe.test_runner import make_test_records from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation +from erpnext.manufacturing.doctype.bom.bom import make_variant_bom from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost -from six import string_types from erpnext.stock.doctype.item.test_item import make_item from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.tests.test_subcontracting import set_backflush_based_on test_records = frappe.get_test_records('BOM') @@ -122,7 +123,7 @@ class TestBOM(unittest.TestCase): bom.items[0].conversion_factor = 5 bom.insert() - bom.update_cost() + bom.update_cost(update_hour_rate = False) # test amounts in selected currency self.assertEqual(bom.items[0].rate, 300) @@ -160,6 +161,7 @@ class TestBOM(unittest.TestCase): def test_subcontractor_sourced_item(self): item_code = "_Test Subcontracted FG Item 1" + set_backflush_based_on('Material Transferred for Subcontract') if not frappe.db.exists('Item', item_code): make_item(item_code, { @@ -225,11 +227,119 @@ class TestBOM(unittest.TestCase): supplied_items = sorted([d.rm_item_code for d in po.supplied_items]) self.assertEqual(bom_items, supplied_items) + def test_bom_tree_representation(self): + bom_tree = { + "Assembly": { + "SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},}, + "SubAssembly2": {"ChildPart3": {}}, + "SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}}, + "ChildPart5": {}, + "ChildPart6": {}, + "SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + created_tree = parent_bom.get_tree_representation() + + reqd_order = level_order_traversal(bom_tree)[1:] # skip first item + created_order = created_tree.level_order_traversal() + + self.assertEqual(len(reqd_order), len(created_order)) + + for reqd_item, created_item in zip(reqd_order, created_order): + self.assertEqual(reqd_item, created_item.item_code) + + def test_generated_variant_bom(self): + from erpnext.controllers.item_variant import create_variant + + template_item = make_item( + "_TestTemplateItem", {"has_variants": 1, "attributes": [{"attribute": "Test Size"},]} + ) + variant = create_variant(template_item.item_code, {"Test Size": "Large"}) + variant.insert(ignore_if_duplicate=True) + + bom_tree = { + template_item.item_code: { + "SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},}, + "ChildPart5": {}, + } + } + template_bom = create_nested_bom(bom_tree, prefix="") + variant_bom = make_variant_bom( + template_bom.name, template_bom.name, variant.item_code, variant_items=[] + ) + variant_bom.save() + + reqd_order = template_bom.get_tree_representation().level_order_traversal() + created_order = variant_bom.get_tree_representation().level_order_traversal() + + self.assertEqual(len(reqd_order), len(created_order)) + + for reqd_item, created_item in zip(reqd_order, created_order): + self.assertEqual(reqd_item.item_code, created_item.item_code) + self.assertEqual(reqd_item.qty, created_item.qty) + self.assertEqual(reqd_item.exploded_qty, created_item.exploded_qty) + + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) + + + +def level_order_traversal(node): + traversal = [] + q = deque() + q.append(node) + + while q: + node = q.popleft() + + for node_name, subtree in node.items(): + traversal.append(node_name) + q.append(subtree) + + return traversal + +def create_nested_bom(tree, prefix="_Test bom "): + """ Helper function to create a simple nested bom from tree describing item names. (along with required items) + """ + + def create_items(bom_tree): + for item_code, subtree in bom_tree.items(): + bom_item_code = prefix + item_code + if not frappe.db.exists("Item", bom_item_code): + frappe.get_doc(doctype="Item", item_code=bom_item_code, item_group="_Test Item Group").insert() + create_items(subtree) + create_items(tree) + + def dfs(tree, node): + """naive implementation for searching right subtree""" + for node_name, subtree in tree.items(): + if node_name == node: + return subtree + else: + result = dfs(subtree, node) + if result is not None: + return result + + order_of_creating_bom = reversed(level_order_traversal(tree)) + + for item in order_of_creating_bom: + child_items = dfs(tree, item) + if child_items: + bom_item_code = prefix + item + bom = frappe.get_doc(doctype="BOM", item=bom_item_code) + for child_item in child_items.keys(): + bom.append("items", {"item_code": prefix + child_item}) + bom.insert() + bom.submit() + + return bom # parent bom is last bom + + def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=None): - if warehouse_list and isinstance(warehouse_list, string_types): + if warehouse_list and isinstance(warehouse_list, str): warehouse_list = [warehouse_list] if not warehouse_list: diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 07464e3e766..4458e6db234 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -13,10 +13,10 @@ "col_break1", "hour_rate", "time_in_mins", - "batch_size", "operating_cost", "base_hour_rate", "base_operating_cost", + "batch_size", "image" ], "fields": [ @@ -61,6 +61,8 @@ }, { "description": "In minutes", + "fetch_from": "operation.total_operation_time", + "fetch_if_empty": 1, "fieldname": "time_in_mins", "fieldtype": "Float", "in_list_view": 1, @@ -104,7 +106,8 @@ "label": "Image" }, { - "default": "1", + "fetch_from": "operation.batch_size", + "fetch_if_empty": 1, "fieldname": "batch_size", "fieldtype": "Int", "label": "Batch Size" @@ -120,7 +123,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-13 18:14:10.018774", + "modified": "2021-01-12 14:48:09.596843", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 4e8dd41022b..81860c9fbcf 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -11,6 +11,16 @@ frappe.ui.form.on('Job Card', { } }; }); + + frm.set_indicator_formatter('sub_operation', + function(doc) { + if (doc.status == "Pending") { + return "red"; + } else { + return doc.status === "Complete" ? "green" : "orange"; + } + } + ); }, refresh: function(frm) { @@ -31,6 +41,10 @@ frappe.ui.form.on('Job Card', { } } + if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card) { + frm.trigger('setup_corrective_job_card'); + } + frm.set_query("quality_inspection", function() { return { query: "erpnext.stock.doctype.quality_inspection.quality_inspection.quality_inspection_query", @@ -43,12 +57,62 @@ frappe.ui.form.on('Job Card', { frm.trigger("toggle_operation_number"); - if (frm.doc.docstatus == 0 && (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) + if (frm.doc.docstatus == 0 && !frm.is_new() && + (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) && (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)) { frm.trigger("prepare_timer_buttons"); } }, + setup_corrective_job_card: function(frm) { + frm.add_custom_button(__('Corrective Job Card'), () => { + let operations = frm.doc.sub_operations.map(d => d.sub_operation).concat(frm.doc.operation); + + let fields = [ + { + fieldtype: 'Link', label: __('Corrective Operation'), options: 'Operation', + fieldname: 'operation', get_query() { + return { + filters: { + "is_corrective_operation": 1 + } + }; + } + }, { + fieldtype: 'Link', label: __('For Operation'), options: 'Operation', + fieldname: 'for_operation', get_query() { + return { + filters: { + "name": ["in", operations] + } + }; + } + } + ]; + + frappe.prompt(fields, d => { + frm.events.make_corrective_job_card(frm, d.operation, d.for_operation); + }, __("Select Corrective Operation")); + }, __('Make')); + }, + + make_corrective_job_card: function(frm, operation, for_operation) { + frappe.call({ + method: 'erpnext.manufacturing.doctype.job_card.job_card.make_corrective_job_card', + args: { + source_name: frm.doc.name, + operation: operation, + for_operation: for_operation + }, + callback: function(r) { + if (r.message) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + } + }); + }, + operation: function(frm) { frm.trigger("toggle_operation_number"); @@ -97,101 +161,105 @@ frappe.ui.form.on('Job Card', { prepare_timer_buttons: function(frm) { frm.trigger("make_dashboard"); - if (!frm.doc.job_started) { - frm.add_custom_button(__("Start"), () => { - if (!frm.doc.employee) { - frappe.prompt({fieldtype: 'Link', label: __('Employee'), options: "Employee", - fieldname: 'employee'}, d => { - if (d.employee) { - frm.set_value("employee", d.employee); - } else { - frm.events.start_job(frm); - } - }, __("Enter Value"), __("Start")); + + if (!frm.doc.started_time && !frm.doc.current_time) { + frm.add_custom_button(__("Start Job"), () => { + if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { + frappe.prompt({fieldtype: 'Table MultiSelect', label: __('Select Employees'), + options: "Job Card Time Log", fieldname: 'employees'}, d => { + frm.events.start_job(frm, "Work In Progress", d.employees); + }, __("Assign Job to Employee")); } else { - frm.events.start_job(frm); + frm.events.start_job(frm, "Work In Progress", frm.doc.employee); } }).addClass("btn-primary"); } else if (frm.doc.status == "On Hold") { - frm.add_custom_button(__("Resume"), () => { - frappe.flags.resume_job = 1; - frm.events.start_job(frm); + frm.add_custom_button(__("Resume Job"), () => { + frm.events.start_job(frm, "Resume Job", frm.doc.employee); }).addClass("btn-primary"); } else { - frm.add_custom_button(__("Pause"), () => { - frappe.flags.pause_job = 1; - frm.set_value("status", "On Hold"); - frm.events.complete_job(frm); + frm.add_custom_button(__("Pause Job"), () => { + frm.events.complete_job(frm, "On Hold"); }); - frm.add_custom_button(__("Complete"), () => { - let completed_time = frappe.datetime.now_datetime(); - frm.trigger("hide_timer"); + frm.add_custom_button(__("Complete Job"), () => { + var sub_operations = frm.doc.sub_operations; - if (frm.doc.for_quantity) { + let set_qty = true; + if (sub_operations && sub_operations.length > 1) { + set_qty = false; + let last_op_row = sub_operations[sub_operations.length - 2]; + + if (last_op_row.status == 'Complete') { + set_qty = true; + } + } + + if (set_qty) { frappe.prompt({fieldtype: 'Float', label: __('Completed Quantity'), - fieldname: 'qty', reqd: 1, default: frm.doc.for_quantity}, data => { - frm.events.complete_job(frm, completed_time, data.qty); - }, __("Enter Value"), __("Complete")); + fieldname: 'qty', default: frm.doc.for_quantity}, data => { + frm.events.complete_job(frm, "Complete", data.qty); + }, __("Enter Value")); } else { - frm.events.complete_job(frm, completed_time, 0); + frm.events.complete_job(frm, "Complete", 0.0); } }).addClass("btn-primary"); } }, - start_job: function(frm) { - let row = frappe.model.add_child(frm.doc, 'Job Card Time Log', 'time_logs'); - row.from_time = frappe.datetime.now_datetime(); - frm.set_value('job_started', 1); - frm.set_value('started_time' , row.from_time); - frm.set_value("status", "Work In Progress"); - - if (!frappe.flags.resume_job) { - frm.set_value('current_time' , 0); - } - - frm.save(); + start_job: function(frm, status, employee) { + const args = { + job_card_id: frm.doc.name, + start_time: frappe.datetime.now_datetime(), + employees: employee, + status: status + }; + frm.events.make_time_log(frm, args); }, - complete_job: function(frm, completed_time, completed_qty) { - frm.doc.time_logs.forEach(d => { - if (d.from_time && !d.to_time) { - d.to_time = completed_time || frappe.datetime.now_datetime(); - d.completed_qty = completed_qty || 0; + complete_job: function(frm, status, completed_qty) { + const args = { + job_card_id: frm.doc.name, + complete_time: frappe.datetime.now_datetime(), + status: status, + completed_qty: completed_qty + }; + frm.events.make_time_log(frm, args); + }, - if(frappe.flags.pause_job) { - let currentIncrement = moment(d.to_time).diff(moment(d.from_time),"seconds") || 0; - frm.set_value('current_time' , currentIncrement + (frm.doc.current_time || 0)); - } else { - frm.set_value('started_time' , ''); - frm.set_value('job_started', 0); - frm.set_value('current_time' , 0); - } + make_time_log: function(frm, args) { + frm.events.update_sub_operation(frm, args); - frm.save(); + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_time_log", + args: { + args: args + }, + freeze: true, + callback: function () { + frm.reload_doc(); + frm.trigger("make_dashboard"); } }); }, + update_sub_operation: function(frm, args) { + if (frm.doc.sub_operations && frm.doc.sub_operations.length) { + let sub_operations = frm.doc.sub_operations.filter(d => d.status != 'Complete'); + if (sub_operations && sub_operations.length) { + args["sub_operation"] = sub_operations[0].sub_operation; + } + } + }, + validate: function(frm) { if ((!frm.doc.time_logs || !frm.doc.time_logs.length) && frm.doc.started_time) { frm.trigger("reset_timer"); } }, - employee: function(frm) { - if (frm.doc.job_started && !frm.doc.current_time) { - frm.trigger("reset_timer"); - } else { - frm.events.start_job(frm); - } - }, - reset_timer: function(frm) { frm.set_value('started_time' , ''); - frm.set_value('job_started', 0); - frm.set_value('current_time' , 0); }, make_dashboard: function(frm) { @@ -297,7 +365,6 @@ frappe.ui.form.on('Job Card Time Log', { }, to_time: function(frm) { - frm.set_value('job_started', 0); frm.set_value('started_time', ''); } }) \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 5713f697e99..046e2fd1825 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -9,38 +9,49 @@ "naming_series", "work_order", "bom_no", - "workstation", - "operation", - "operation_row_number", "column_break_4", "posting_date", "company", - "remarks", "production_section", "production_item", "item_name", "for_quantity", - "quality_inspection", - "wip_warehouse", + "serial_no", "column_break_12", - "employee", - "employee_name", - "status", + "wip_warehouse", + "quality_inspection", "project", + "batch_no", + "operation_section_section", + "operation", + "operation_row_number", + "column_break_18", + "workstation", + "employee", + "section_break_21", + "sub_operations", "timing_detail", "time_logs", "section_break_13", "total_completed_qty", - "total_time_in_mins", "column_break_15", + "total_time_in_mins", "section_break_8", "items", + "corrective_operation_section", + "for_job_card", + "is_corrective_job_card", + "column_break_33", + "hour_rate", + "for_operation", "more_information", "operation_id", "sequence_id", "transferred_qty", "requested_qty", + "status", "column_break_20", + "remarks", "barcode", "job_started", "started_time", @@ -117,13 +128,6 @@ "fieldtype": "Section Break", "label": "Timing Detail" }, - { - "fieldname": "employee", - "fieldtype": "Link", - "in_standard_filter": 1, - "label": "Employee", - "options": "Employee" - }, { "allow_bulk_edit": 1, "fieldname": "time_logs", @@ -133,9 +137,11 @@ }, { "fieldname": "section_break_13", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { + "default": "0", "fieldname": "total_completed_qty", "fieldtype": "Float", "label": "Total Completed Qty", @@ -160,8 +166,7 @@ "fieldname": "items", "fieldtype": "Table", "label": "Items", - "options": "Job Card Item", - "read_only": 1 + "options": "Job Card Item" }, { "collapsible": 1, @@ -251,12 +256,7 @@ "reqd": 1 }, { - "fetch_from": "employee.employee_name", - "fieldname": "employee_name", - "fieldtype": "Read Only", - "label": "Employee Name" - }, - { + "collapsible": 1, "fieldname": "production_section", "fieldtype": "Section Break", "label": "Production" @@ -314,11 +314,89 @@ "label": "Quality Inspection", "no_copy": 1, "options": "Quality Inspection" + }, + { + "allow_bulk_edit": 1, + "fieldname": "sub_operations", + "fieldtype": "Table", + "label": "Sub Operations", + "options": "Job Card Operation", + "read_only": 1 + }, + { + "fieldname": "operation_section_section", + "fieldtype": "Section Break", + "label": "Operation Section" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_21", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "is_corrective_job_card", + "fieldname": "hour_rate", + "fieldtype": "Currency", + "label": "Hour Rate" + }, + { + "collapsible": 1, + "depends_on": "is_corrective_job_card", + "fieldname": "corrective_operation_section", + "fieldtype": "Section Break", + "label": "Corrective Operation" + }, + { + "default": "0", + "fieldname": "is_corrective_job_card", + "fieldtype": "Check", + "label": "Is Corrective Job Card", + "read_only": 1 + }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, + { + "fieldname": "for_job_card", + "fieldtype": "Link", + "label": "For Job Card", + "options": "Job Card", + "read_only": 1 + }, + { + "fetch_from": "for_job_card.operation", + "fetch_if_empty": 1, + "fieldname": "for_operation", + "fieldtype": "Link", + "label": "For Operation", + "options": "Operation" + }, + { + "fieldname": "employee", + "fieldtype": "Table MultiSelect", + "label": "Employee", + "options": "Job Card Time Log" + }, + { + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial No" + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": "Batch No", + "options": "Batch" } ], "is_submittable": 1, "links": [], - "modified": "2020-11-19 18:26:50.531664", + "modified": "2021-03-16 15:59:32.766484", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index cdc45188942..69c7f5c614b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -5,11 +5,12 @@ from __future__ import unicode_literals import frappe import datetime +import json from frappe import _, bold from frappe.model.mapper import get_mapped_doc from frappe.model.document import Document from frappe.utils import (flt, cint, time_diff_in_hours, get_datetime, getdate, - get_time, add_to_date, time_diff, add_days, get_datetime_str, get_link_to_form) + get_time, add_to_date, time_diff, add_days, get_datetime_str, get_link_to_form, time_diff_in_seconds) from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import get_mins_between_operations @@ -25,10 +26,21 @@ class JobCard(Document): self.set_status() self.validate_operation_id() self.validate_sequence_id() + self.get_sub_operations() + self.update_sub_operation_status() + + def get_sub_operations(self): + if self.operation: + self.sub_operations = [] + for row in frappe.get_all("Sub Operation", + filters = {"parent": self.operation}, fields=["operation", "idx"]): + row.status = "Pending" + row.sub_operation = row.operation + self.append("sub_operations", row) def validate_time_logs(self): - self.total_completed_qty = 0.0 self.total_time_in_mins = 0.0 + self.total_completed_qty = 0.0 if self.get('time_logs'): for d in self.get('time_logs'): @@ -44,11 +56,14 @@ class JobCard(Document): d.time_in_mins = time_diff_in_hours(d.to_time, d.from_time) * 60 self.total_time_in_mins += d.time_in_mins - if d.completed_qty: + if d.completed_qty and not self.sub_operations: self.total_completed_qty += d.completed_qty self.total_completed_qty = flt(self.total_completed_qty, self.precision("total_completed_qty")) + for row in self.sub_operations: + self.total_completed_qty += row.completed_qty + def get_overlap_for(self, args, check_next_available_slot=False): production_capacity = 1 @@ -57,7 +72,7 @@ class JobCard(Document): self.workstation, 'production_capacity') or 1 validate_overlap_for = " and jc.workstation = %(workstation)s " - if self.employee: + if args.get("employee"): # override capacity for employee production_capacity = 1 validate_overlap_for = " and jc.employee = %(employee)s " @@ -80,7 +95,7 @@ class JobCard(Document): "to_time": args.to_time, "name": args.name or "No Name", "parent": args.parent or "No Name", - "employee": self.employee, + "employee": args.get("employee"), "workstation": self.workstation }, as_dict=True) @@ -158,6 +173,108 @@ class JobCard(Document): row.planned_start_time = datetime.datetime.combine(start_date, get_time(workstation_doc.working_hours[0].start_time)) + def add_time_log(self, args): + last_row = [] + employees = args.employees + if isinstance(employees, str): + employees = json.loads(employees) + + if self.time_logs and len(self.time_logs) > 0: + last_row = self.time_logs[-1] + + self.reset_timer_value(args) + if last_row and args.get("complete_time"): + for row in self.time_logs: + if not row.to_time: + row.update({ + "to_time": get_datetime(args.get("complete_time")), + "operation": args.get("sub_operation"), + "completed_qty": args.get("completed_qty") or 0.0 + }) + elif args.get("start_time"): + new_args = frappe._dict({ + "from_time": get_datetime(args.get("start_time")), + "operation": args.get("sub_operation"), + "completed_qty": 0.0 + }) + + if employees: + for name in employees: + new_args.employee = name.get('employee') + self.add_start_time_log(new_args) + else: + self.add_start_time_log(new_args) + + if not self.employee and employees: + self.set_employees(employees) + + if self.status == "On Hold": + self.current_time = time_diff_in_seconds(last_row.to_time, last_row.from_time) + + self.save() + + def add_start_time_log(self, args): + self.append("time_logs", args) + + def set_employees(self, employees): + for name in employees: + self.append('employee', { + 'employee': name.get('employee'), + 'completed_qty': 0.0 + }) + + def reset_timer_value(self, args): + self.started_time = None + + if args.get("status") in ["Work In Progress", "Complete"]: + self.current_time = 0.0 + + if args.get("status") == "Work In Progress": + self.started_time = get_datetime(args.get("start_time")) + + if args.get("status") == "Resume Job": + args["status"] = "Work In Progress" + + if args.get("status"): + self.status = args.get("status") + + def update_sub_operation_status(self): + if not (self.sub_operations and self.time_logs): + return + + operation_wise_completed_time = {} + for time_log in self.time_logs: + if time_log.operation not in operation_wise_completed_time: + operation_wise_completed_time.setdefault(time_log.operation, + frappe._dict({"status": "Pending", "completed_qty":0.0, "completed_time": 0.0, "employee": []})) + + op_row = operation_wise_completed_time[time_log.operation] + op_row.status = "Work In Progress" if not time_log.time_in_mins else "Complete" + if self.status == 'On Hold': + op_row.status = 'Pause' + + op_row.employee.append(time_log.employee) + if time_log.time_in_mins: + op_row.completed_time += time_log.time_in_mins + op_row.completed_qty += time_log.completed_qty + + for row in self.sub_operations: + operation_deatils = operation_wise_completed_time.get(row.sub_operation) + if operation_deatils: + if row.status != 'Complete': + row.status = operation_deatils.status + + row.completed_time = operation_deatils.completed_time + if operation_deatils.employee: + row.completed_time = row.completed_time / len(set(operation_deatils.employee)) + + if operation_deatils.completed_qty: + row.completed_qty = operation_deatils.completed_qty / len(set(operation_deatils.employee)) + else: + row.status = 'Pending' + row.completed_time = 0.0 + row.completed_qty = 0.0 + def update_time_logs(self, row): self.append("time_logs", { "from_time": row.planned_start_time, @@ -182,15 +299,18 @@ class JobCard(Document): if self.get('operation') == d.operation: self.append('items', { - 'item_code': d.item_code, - 'source_warehouse': d.source_warehouse, - 'uom': frappe.db.get_value("Item", d.item_code, 'stock_uom'), - 'item_name': d.item_name, - 'description': d.description, - 'required_qty': (d.required_qty * flt(self.for_quantity)) / doc.qty + "item_code": d.item_code, + "source_warehouse": d.source_warehouse, + "uom": frappe.db.get_value("Item", d.item_code, 'stock_uom'), + "item_name": d.item_name, + "description": d.description, + "required_qty": (d.required_qty * flt(self.for_quantity)) / doc.qty, + "rate": d.rate, + "amount": d.amount }) def on_submit(self): + self.validate_transfer_qty() self.validate_job_card() self.update_work_order() self.set_transferred_qty() @@ -199,7 +319,16 @@ class JobCard(Document): self.update_work_order() self.set_transferred_qty() + def validate_transfer_qty(self): + if self.items and self.transferred_qty < self.for_quantity: + frappe.throw(_('Materials needs to be transferred to the work in progress warehouse for the job card {0}') + .format(self.name)) + def validate_job_card(self): + if self.work_order and frappe.get_cached_value('Work Order', self.work_order, 'status') == 'Stopped': + frappe.throw(_("Transaction not allowed against stopped Work Order {0}") + .format(get_link_to_form('Work Order', self.work_order))) + if not self.time_logs: frappe.throw(_("Time logs are required for {0} {1}") .format(bold("Job Card"), get_link_to_form("Job Card", self.name))) @@ -215,6 +344,10 @@ class JobCard(Document): if not self.work_order: return + if self.is_corrective_job_card and not cint(frappe.db.get_single_value('Manufacturing Settings', + 'add_corrective_operation_cost_in_finished_good_valuation')): + return + for_quantity, time_in_mins = 0, 0 from_time_list, to_time_list = [], [] @@ -225,10 +358,24 @@ class JobCard(Document): time_in_mins = flt(data[0].time_in_mins) wo = frappe.get_doc('Work Order', self.work_order) - if self.operation_id: + + if self.is_corrective_job_card: + self.update_corrective_in_work_order(wo) + + elif self.operation_id: self.validate_produced_quantity(for_quantity, wo) self.update_work_order_data(for_quantity, time_in_mins, wo) + def update_corrective_in_work_order(self, wo): + wo.corrective_operation_cost = 0.0 + for row in frappe.get_all('Job Card', fields = ['total_time_in_mins', 'hour_rate'], + filters = {'is_corrective_job_card': 1, 'docstatus': 1, 'work_order': self.work_order}): + wo.corrective_operation_cost += flt(row.total_time_in_mins) * flt(row.hour_rate) + + wo.calculate_operating_cost() + wo.flags.ignore_validate_update_after_submit = True + wo.save() + def validate_produced_quantity(self, for_quantity, wo): if self.docstatus < 2: return @@ -248,8 +395,8 @@ class JobCard(Document): min(from_time) as start_time, max(to_time) as end_time FROM `tabJob Card` jc, `tabJob Card Time Log` jctl WHERE - jctl.parent = jc.name and jc.work_order = %s - and jc.operation_id = %s and jc.docstatus = 1 + jctl.parent = jc.name and jc.work_order = %s and jc.operation_id = %s + and jc.docstatus = 1 and IFNULL(jc.is_corrective_job_card, 0) = 0 """, (self.work_order, self.operation_id), as_dict=1) for data in wo.operations: @@ -271,7 +418,8 @@ class JobCard(Document): def get_current_operation_data(self): return frappe.get_all('Job Card', fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], - filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id}) + filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id, + "is_corrective_job_card": 0}) def set_transferred_qty_in_job_card(self, ste_doc): for row in ste_doc.items: @@ -354,7 +502,11 @@ class JobCard(Document): .format(bold(self.operation), work_order), OperationMismatchError) def validate_sequence_id(self): - if not (self.work_order and self.sequence_id): return + if self.is_corrective_job_card: + return + + if not (self.work_order and self.sequence_id): + return current_operation_qty = 0.0 data = self.get_current_operation_data() @@ -376,6 +528,17 @@ class JobCard(Document): frappe.throw(_("{0}, complete the operation {1} before the operation {2}.") .format(message, bold(row.operation), bold(self.operation)), OperationSequenceError) + +@frappe.whitelist() +def make_time_log(args): + if isinstance(args, str): + args = json.loads(args) + + args = frappe._dict(args) + doc = frappe.get_doc("Job Card", args.job_card_id) + doc.validate_sequence_id() + doc.add_time_log(args) + @frappe.whitelist() def get_operation_details(work_order, operation): if work_order and operation: @@ -511,3 +674,28 @@ def get_job_details(start, end, filters=None): events.append(job_card_data) return events + +@frappe.whitelist() +def make_corrective_job_card(source_name, operation=None, for_operation=None, target_doc=None): + def set_missing_values(source, target): + target.is_corrective_job_card = 1 + target.operation = operation + target.for_operation = for_operation + + target.set('time_logs', []) + target.set('employee', []) + target.set('items', []) + target.get_sub_operations() + target.get_required_items() + target.validate_time_logs() + + doclist = get_mapped_doc("Job Card", source_name, { + "Job Card": { + "doctype": "Job Card", + "field_map": { + "name": "for_job_card", + }, + } + }, target_doc, set_missing_values) + + return doclist \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index 100ef4ca3a3..d91530dd3b5 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -25,8 +25,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Item Code", - "options": "Item", - "read_only": 1 + "options": "Item" }, { "fieldname": "source_warehouse", @@ -67,8 +66,7 @@ "fieldname": "required_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Required Qty", - "read_only": 1 + "label": "Required Qty" }, { "fieldname": "column_break_9", @@ -107,7 +105,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-11 13:50:13.804108", + "modified": "2021-04-22 18:50:00.003444", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Item", diff --git a/erpnext/manufacturing/doctype/job_card_operation/__init__.py b/erpnext/manufacturing/doctype/job_card_operation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json new file mode 100644 index 00000000000..9a8692b84d9 --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json @@ -0,0 +1,59 @@ +{ + "actions": [], + "creation": "2020-12-07 16:58:38.449041", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "sub_operation", + "completed_time", + "status", + "completed_qty" + ], + "fields": [ + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Complete\nPause\nPending\nWork In Progress", + "read_only": 1 + }, + { + "description": "In mins", + "fieldname": "completed_time", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Completed Time", + "read_only": 1 + }, + { + "fieldname": "sub_operation", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Operation", + "options": "Operation", + "read_only": 1 + }, + { + "fieldname": "completed_qty", + "fieldtype": "Float", + "label": "Completed Qty", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-03-16 18:24:35.399593", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Job Card Operation", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.py b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.py new file mode 100644 index 00000000000..85d72982ed3 --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class JobCardOperation(Document): + pass diff --git a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json index 9dd54dd6182..a7102d7d237 100644 --- a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json +++ b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json @@ -1,14 +1,17 @@ { + "actions": [], "creation": "2019-03-08 23:56:43.187569", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "employee", "from_time", "to_time", "column_break_2", "time_in_mins", - "completed_qty" + "completed_qty", + "operation" ], "fields": [ { @@ -41,10 +44,27 @@ "in_list_view": 1, "label": "Completed Qty", "reqd": 1 + }, + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Employee", + "options": "Employee" + }, + { + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "no_copy": 1, + "options": "Operation", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-12-03 12:56:02.285448", + "links": [], + "modified": "2020-12-23 14:30:00.970916", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Time Log", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index b7634da87c2..024f7847259 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -26,7 +26,10 @@ "column_break_16", "overproduction_percentage_for_work_order", "other_settings_section", - "update_bom_costs_automatically" + "update_bom_costs_automatically", + "add_corrective_operation_cost_in_finished_good_valuation", + "column_break_23", + "make_serial_no_batch_from_work_order" ], "fields": [ { @@ -155,13 +158,30 @@ { "fieldname": "column_break_5", "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "System will automatically create the serial numbers / batch for the Finished Good on submission of work order", + "fieldname": "make_serial_no_batch_from_work_order", + "fieldtype": "Check", + "label": "Make Serial No / Batch from Work Order" + }, + { + "default": "0", + "fieldname": "add_corrective_operation_cost_in_finished_good_valuation", + "fieldtype": "Check", + "label": "Add Corrective Operation Cost in Finished Good Valuation" } ], "icon": "icon-wrench", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-10-13 10:55:43.996581", + "modified": "2021-03-16 15:54:38.967341", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", @@ -178,4 +198,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/operation/operation.js b/erpnext/manufacturing/doctype/operation/operation.js index 5c2aba6f095..102b6780e5f 100644 --- a/erpnext/manufacturing/doctype/operation/operation.js +++ b/erpnext/manufacturing/doctype/operation/operation.js @@ -2,7 +2,13 @@ // For license information, please see license.txt frappe.ui.form.on('Operation', { - refresh: function(frm) { - + setup: function(frm) { + frm.set_query('operation', 'sub_operations', function() { + return { + filters: { + 'name': ['not in', [frm.doc.name]] + } + }; + }); } -}); +}); \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/operation/operation.json b/erpnext/manufacturing/doctype/operation/operation.json index c231fba2faa..10a97eda763 100644 --- a/erpnext/manufacturing/doctype/operation/operation.json +++ b/erpnext/manufacturing/doctype/operation/operation.json @@ -1,167 +1,132 @@ { - "allow_copy": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "Prompt", - "beta": 0, - "creation": "2014-11-07 16:20:30.683186", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, - "engine": "InnoDB", + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2014-11-07 16:20:30.683186", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "workstation", + "data_2", + "is_corrective_operation", + "job_card_section", + "create_job_card_based_on_batch_size", + "column_break_6", + "batch_size", + "sub_operations_section", + "sub_operations", + "total_operation_time", + "section_break_4", + "description" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "workstation", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Default Workstation", - "length": 0, - "no_copy": 0, - "options": "Workstation", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "workstation", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Default Workstation", + "options": "Workstation" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_4", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "collapsible": 1, + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Operation Description" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "description", + "fieldtype": "Text", + "label": "Description" + }, + { + "collapsible": 1, + "fieldname": "sub_operations_section", + "fieldtype": "Section Break", + "label": "Sub Operations" + }, + { + "fieldname": "sub_operations", + "fieldtype": "Table", + "options": "Sub Operation" + }, + { + "description": "Time in mins.", + "fieldname": "total_operation_time", + "fieldtype": "Float", + "label": "Total Operation Time", + "read_only": 1 + }, + { + "fieldname": "data_2", + "fieldtype": "Column Break" + }, + { + "default": "1", + "depends_on": "create_job_card_based_on_batch_size", + "fieldname": "batch_size", + "fieldtype": "Int", + "label": "Batch Size", + "mandatory_depends_on": "create_job_card_based_on_batch_size" + }, + { + "default": "0", + "fieldname": "create_job_card_based_on_batch_size", + "fieldtype": "Check", + "label": "Create Job Card based on Batch Size" + }, + { + "collapsible": 1, + "fieldname": "job_card_section", + "fieldtype": "Section Break", + "label": "Job Card" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_corrective_operation", + "fieldtype": "Check", + "label": "Is Corrective Operation" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-wrench", - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2016-11-07 05:28:27.462413", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "Operation", - "name_case": "", - "owner": "Administrator", + ], + "icon": "fa fa-wrench", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-12 15:09:23.593338", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Operation", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 0, - "export": 1, - "if_owner": 0, - "import": 1, - "is_custom": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Manufacturing User", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "export": 1, + "import": 1, + "read": 1, + "role": "Manufacturing User", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 0, - "export": 1, - "if_owner": 0, - "import": 1, - "is_custom": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 1, - "role": "Manufacturing Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "export": 1, + "import": 1, + "read": 1, + "report": 1, + "role": "Manufacturing Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/operation/operation.py b/erpnext/manufacturing/doctype/operation/operation.py index 69e83292ff8..374f32019bd 100644 --- a/erpnext/manufacturing/doctype/operation/operation.py +++ b/erpnext/manufacturing/doctype/operation/operation.py @@ -2,9 +2,34 @@ # For license information, please see license.txt from __future__ import unicode_literals + +import frappe +from frappe import _ from frappe.model.document import Document class Operation(Document): def validate(self): if not self.description: self.description = self.name + + self.duplicate_sub_operation() + self.set_total_time() + + def duplicate_sub_operation(self): + operation_list = [] + for row in self.sub_operations: + if row.operation in operation_list: + frappe.throw(_("The operation {0} can not add multiple times") + .format(frappe.bold(row.operation))) + + if self.name == row.operation: + frappe.throw(_("The operation {0} can not be the sub operation") + .format(frappe.bold(row.operation))) + + operation_list.append(row.operation) + + def set_total_time(self): + self.total_operation_time = 0.0 + + for row in self.sub_operations: + self.total_operation_time += row.time_in_mins diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 288c1d0cd66..d198a6962a5 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -4,7 +4,7 @@ frappe.ui.form.on('Production Plan', { setup: function(frm) { frm.custom_make_buttons = { - 'Work Order': 'Work Order', + 'Work Order': 'Work Order / Subcontract PO', 'Material Request': 'Material Request', }; @@ -68,17 +68,13 @@ frappe.ui.form.on('Production Plan', { frm.trigger("show_progress"); if (frm.doc.status !== "Completed") { - if (frm.doc.po_items && frm.doc.status !== "Closed") { - frm.add_custom_button(__("Work Order"), ()=> { - frm.trigger("make_work_order"); - }, __('Create')); - } + frm.add_custom_button(__("Work Order Tree"), ()=> { + frappe.set_route('Tree', 'Work Order', {production_plan: frm.doc.name}); + }, __('View')); - if (frm.doc.mr_items && !in_list(['Material Requested', 'Closed'], frm.doc.status)) { - frm.add_custom_button(__("Material Request"), ()=> { - frm.trigger("make_material_request"); - }, __('Create')); - } + frm.add_custom_button(__("Production Plan Summary"), ()=> { + frappe.set_route('query-report', 'Production Plan Summary', {production_plan: frm.doc.name}); + }, __('View')); if (frm.doc.status === "Closed") { frm.add_custom_button(__("Re-open"), function() { @@ -89,6 +85,18 @@ frappe.ui.form.on('Production Plan', { frm.events.close_open_production_plan(frm, true); }, __("Status")); } + + if (frm.doc.po_items && frm.doc.status !== "Closed") { + frm.add_custom_button(__("Work Order / Subcontract PO"), ()=> { + frm.trigger("make_work_order"); + }, __('Create')); + } + + if (frm.doc.mr_items && !in_list(['Material Requested', 'Closed'], frm.doc.status)) { + frm.add_custom_button(__("Material Request"), ()=> { + frm.trigger("make_material_request"); + }, __('Create')); + } } } @@ -211,16 +219,38 @@ frappe.ui.form.on('Production Plan', { }); }, - get_items: function(frm) { + get_items: function (frm) { + frm.clear_table('prod_plan_references'); + frappe.call({ method: "get_items", freeze: true, doc: frm.doc, - callback: function() { + callback: function () { refresh_field('po_items'); } }); }, + combine_items: function (frm) { + frm.clear_table('prod_plan_references'); + + frappe.call({ + method: "get_items", + freeze: true, + doc: frm.doc, + }); + }, + + get_sub_assembly_items: function(frm) { + frappe.call({ + method: "get_sub_assembly_items", + freeze: true, + doc: frm.doc, + callback: function() { + refresh_field("sub_assembly_items"); + } + }); + }, get_items_for_mr: function(frm) { if (!frm.doc.for_warehouse) { @@ -295,8 +325,25 @@ frappe.ui.form.on('Production Plan', { }, download_materials_required: function(frm) { - let get_template_url = 'erpnext.manufacturing.doctype.production_plan.production_plan.download_raw_materials'; - open_url_post(frappe.request.url, { cmd: get_template_url, doc: frm.doc }); + const fields = [{ + fieldname: 'warehouses', + fieldtype: 'Table MultiSelect', + label: __('Warehouses'), + default: frm.doc.from_warehouse, + options: "Production Plan Material Request Warehouse", + get_query: function () { + return { + filters: { + company: frm.doc.company + } + }; + }, + }]; + + frappe.prompt(fields, (row) => { + let get_template_url = 'erpnext.manufacturing.doctype.production_plan.production_plan.download_raw_materials'; + open_url_post(frappe.request.url, { cmd: get_template_url, doc: frm.doc, warehouses: row.warehouses }); + }, __('Select Warehouses to get Stock for Materials Planning'), __('Get Stock')); }, show_progress: function(frm) { diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index f11470086af..84378956c61 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -28,7 +28,13 @@ "material_requests", "select_items_to_manufacture_section", "get_items", + "combine_items", "po_items", + "section_break_25", + "prod_plan_references", + "section_break_24", + "get_sub_assembly_items", + "sub_assembly_items", "material_request_planning", "include_non_stock_items", "include_subcontracted_items", @@ -184,7 +190,7 @@ "depends_on": "get_items_from", "fieldname": "get_items", "fieldtype": "Button", - "label": "Get Items For Work Order" + "label": "Get Finished Goods for Manufacture" }, { "fieldname": "po_items", @@ -196,7 +202,7 @@ { "fieldname": "material_request_planning", "fieldtype": "Section Break", - "label": "Material Request Planning" + "label": "Material Requirement Planning" }, { "default": "1", @@ -234,12 +240,13 @@ }, { "fieldname": "section_break_27", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "mr_items", "fieldtype": "Table", - "label": "Material Request Plan Item", + "label": "Raw Materials", "no_copy": 1, "options": "Material Request Plan Item" }, @@ -316,13 +323,48 @@ "fieldname": "include_safety_stock", "fieldtype": "Check", "label": "Include Safety Stock in Required Qty Calculation" + }, + { + "default": "0", + "depends_on": "eval:doc.get_items_from == 'Sales Order'", + "fieldname": "combine_items", + "fieldtype": "Check", + "label": "Consolidate Items" + }, + { + "fieldname": "section_break_25", + "fieldtype": "Section Break" + }, + { + "fieldname": "prod_plan_references", + "fieldtype": "Table", + "hidden": 1, + "label": "Production Plan Item Reference", + "options": "Production Plan Item Reference" + }, + { + "fieldname": "section_break_24", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "sub_assembly_items", + "fieldtype": "Table", + "label": "Sub Assembly Items", + "no_copy": 1, + "options": "Production Plan Sub Assembly Item" + }, + { + "fieldname": "get_sub_assembly_items", + "fieldtype": "Button", + "label": "Get Sub Assembly Items" } ], "icon": "fa fa-calendar", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-03-08 11:17:25.470147", + "modified": "2021-06-28 20:00:33.905114", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index a3e23a68972..6a024f275aa 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -5,10 +5,11 @@ from __future__ import unicode_literals import frappe, json, copy from frappe import msgprint, _ -from six import string_types, iteritems +from six import iteritems from frappe.model.document import Document -from frappe.utils import cstr, flt, cint, nowdate, add_days, comma_and, now_datetime, ceil +from frappe.utils import (flt, cint, nowdate, add_days, comma_and, now_datetime, + ceil, get_link_to_form, getdate) from frappe.utils.csvutils import build_csv_response from erpnext.manufacturing.doctype.bom.bom import validate_bom_no, get_children from erpnext.manufacturing.doctype.work_order.work_order import get_item_details @@ -96,8 +97,10 @@ class ProductionPlan(Document): @frappe.whitelist() def get_items(self): + self.set('po_items', []) if self.get_items_from == "Sales Order": self.get_so_items() + elif self.get_items_from == "Material Request": self.get_mr_items() @@ -165,9 +168,31 @@ class ProductionPlan(Document): self.calculate_total_planned_qty() def add_items(self, items): - self.set('po_items', []) + refs = {} for data in items: item_details = get_item_details(data.item_code) + if self.combine_items: + if item_details.bom_no in refs: + refs[item_details.bom_no]['so_details'].append({ + 'sales_order': data.parent, + 'sales_order_item': data.name, + 'qty': data.pending_qty + }) + refs[item_details.bom_no]['qty'] += data.pending_qty + continue + + else: + refs[item_details.bom_no] = { + 'qty': data.pending_qty, + 'po_item_ref': data.name, + 'so_details': [] + } + refs[item_details.bom_no]['so_details'].append({ + 'sales_order': data.parent, + 'sales_order_item': data.name, + 'qty': data.pending_qty + }) + pi = self.append('po_items', { 'include_exploded_items': 1, 'warehouse': data.warehouse, @@ -191,6 +216,23 @@ class ProductionPlan(Document): pi.material_request_item = data.name pi.description = data.description + if refs: + for po_item in self.po_items: + po_item.planned_qty = refs[po_item.bom_no]['qty'] + po_item.pending_qty = refs[po_item.bom_no]['qty'] + po_item.sales_order = '' + self.add_pp_ref(refs) + + def add_pp_ref(self, refs): + for bom_no in refs: + for so_detail in refs[bom_no]['so_details']: + self.append('prod_plan_references', { + 'item_reference': refs[bom_no]['po_item_ref'], + 'sales_order': so_detail['sales_order'], + 'sales_order_item': so_detail['sales_order_item'], + 'qty': so_detail['qty'] + }) + def calculate_total_planned_qty(self): self.total_planned_qty = 0 for d in self.po_items: @@ -308,49 +350,88 @@ class ProductionPlan(Document): @frappe.whitelist() def make_work_order(self): - wo_list = [] + wo_list, po_list = [], [] + subcontracted_po = {} + self.validate_data() + self.make_work_order_for_finished_goods(wo_list) + self.make_work_order_for_subassembly_items(wo_list, subcontracted_po) + self.make_subcontracted_purchase_order(subcontracted_po, po_list) + self.show_list_created_message('Work Order', wo_list) + self.show_list_created_message('Purchase Order', po_list) + + def make_work_order_for_finished_goods(self, wo_list): items_data = self.get_production_items() for key, item in items_data.items(): + if self.sub_assembly_items: + item['use_multi_level_bom'] = 0 + work_order = self.create_work_order(item) if work_order: wo_list.append(work_order) - if item.get("make_work_order_for_sub_assembly_items"): - work_orders = self.make_work_order_for_sub_assembly_items(item) - wo_list.extend(work_orders) + def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po): + for row in self.sub_assembly_items: + if row.type_of_manufacturing == 'Subcontract': + subcontracted_po.setdefault(row.supplier, []).append(row) + continue + + args = {} + self.prepare_args_for_sub_assembly_items(row, args) + work_order = self.create_work_order(args) + if work_order: + wo_list.append(work_order) + + def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): + if not subcontracted_po: + return + + for supplier, po_list in subcontracted_po.items(): + po = frappe.new_doc('Purchase Order') + po.supplier = supplier + po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate() + po.is_subcontracted_item = 'Yes' + for row in po_list: + args = { + 'item_code': row.production_item, + 'warehouse': row.fg_warehouse, + 'production_plan_sub_assembly_item': row.name, + 'bom': row.bom_no, + 'production_plan': self.name + } + + for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name', + 'description', 'production_plan_item']: + args[field] = row.get(field) + + po.append('items', args) + + po.set_missing_values() + po.flags.ignore_mandatory = True + po.flags.ignore_validate = True + po.insert() + purchase_orders.append(po.name) + + def show_list_created_message(self, doctype, doc_list=None): + if not doc_list: + return frappe.flags.mute_messages = False + if doc_list: + doc_list = [get_link_to_form(doctype, p) for p in doc_list] + msgprint(_("{0} created").format(comma_and(doc_list))) - if wo_list: - wo_list = ["""%s""" % \ - (p, p) for p in wo_list] - msgprint(_("{0} created").format(comma_and(wo_list))) - else : - msgprint(_("No Work Orders created")) + def prepare_args_for_sub_assembly_items(self, row, args): + for field in ["production_item", "item_name", "qty", "fg_warehouse", + "description", "bom_no", "stock_uom", "bom_level", "production_plan_item"]: + args[field] = row.get(field) - def make_work_order_for_sub_assembly_items(self, item): - work_orders = [] - bom_data = {} - - get_sub_assembly_items(item.get("bom_no"), bom_data, item.get("qty")) - - for key, data in bom_data.items(): - data.update({ - 'qty': data.get("stock_qty"), - 'production_plan': self.name, - 'use_multi_level_bom': item.get("use_multi_level_bom"), - 'company': self.company, - 'fg_warehouse': item.get("fg_warehouse"), - 'update_consumed_material_cost_in_project': 0 - }) - - work_order = self.create_work_order(data) - if work_order: - work_orders.append(work_order) - - return work_orders + args.update({ + "use_multi_level_bom": 0, + "production_plan": self.name, + "production_plan_sub_assembly_item": row.name + }) def create_work_order(self, item): from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError, get_default_warehouse @@ -435,19 +516,43 @@ class ProductionPlan(Document): else : msgprint(_("No material request created")) + @frappe.whitelist() + def get_sub_assembly_items(self, manufacturing_type=None): + self.sub_assembly_items = [] + for row in self.po_items: + bom_data = [] + get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty) + self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) + + self.save() + + def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): + bom_data = sorted(bom_data, key = lambda i: i.bom_level) + + for data in bom_data: + data.qty = data.stock_qty + data.production_plan_item = row.name + data.fg_warehouse = row.warehouse + data.schedule_date = row.planned_start_date + data.type_of_manufacturing = manufacturing_type or ("Subcontract" if data.is_sub_contracted_item + else "In House") + + self.append("sub_assembly_items", data) + @frappe.whitelist() -def download_raw_materials(doc): - if isinstance(doc, string_types): +def download_raw_materials(doc, warehouses=None): + if isinstance(doc, str): doc = frappe._dict(json.loads(doc)) item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM', - 'Projected Qty', 'Actual Qty', 'Ordered Qty', 'Reserved Qty for Production', - 'Safety Stock', 'Required Qty']] + 'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty', + 'Reserved Qty for Production', 'Safety Stock', 'Required Qty']] - for d in get_items_for_material_requests(doc): + doc.warehouse = None + for d in get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True): item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'), d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'), - d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) + d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) if not doc.get('for_warehouse'): row = {'item_code': d.get('item_code')} @@ -466,7 +571,7 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p ifnull(sum(bei.stock_qty/ifnull(bom.quantity, 1)), 0)*%s as qty, item.item_name, bei.description, bei.stock_uom, item.min_order_qty, bei.source_warehouse, item.default_material_request_type, item.min_order_qty, item_default.default_warehouse, - item.purchase_uom, item_uom.conversion_factor + item.purchase_uom, item_uom.conversion_factor, item.safety_stock from `tabBOM Explosion Item` bei JOIN `tabBOM` bom ON bom.name = bei.parent @@ -618,7 +723,7 @@ def get_sales_orders(self): @frappe.whitelist() def get_bin_details(row, company, for_warehouse=None, all_warehouse=False): - if isinstance(row, string_types): + if isinstance(row, str): row = frappe._dict(json.loads(row)) company = frappe.db.escape(company) @@ -636,36 +741,38 @@ def get_bin_details(row, company, for_warehouse=None, all_warehouse=False): return frappe.db.sql(""" select ifnull(sum(projected_qty),0) as projected_qty, ifnull(sum(actual_qty),0) as actual_qty, ifnull(sum(ordered_qty),0) as ordered_qty, - ifnull(sum(reserved_qty_for_production),0) as reserved_qty_for_production, warehouse from `tabBin` - where item_code = %(item_code)s {conditions} + ifnull(sum(reserved_qty_for_production),0) as reserved_qty_for_production, warehouse, + ifnull(sum(planned_qty),0) as planned_qty + from `tabBin` where item_code = %(item_code)s {conditions} group by item_code, warehouse """.format(conditions=conditions), { "item_code": row['item_code'] }, as_dict=1) +def get_warehouse_list(warehouses): + warehouse_list = [] + + if isinstance(warehouses, str): + warehouses = json.loads(warehouses) + + for row in warehouses: + child_warehouses = frappe.db.get_descendants('Warehouse', row.get("warehouse")) + if child_warehouses: + warehouse_list.extend(child_warehouses) + else: + warehouse_list.append(row.get("warehouse")) + + return warehouse_list + @frappe.whitelist() -def get_items_for_material_requests(doc, warehouses=None): - if isinstance(doc, string_types): +def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_data=None): + if isinstance(doc, str): doc = frappe._dict(json.loads(doc)) - warehouse_list = [] if warehouses: - if isinstance(warehouses, string_types): - warehouses = json.loads(warehouses) + warehouses = list(set(get_warehouse_list(warehouses))) - for row in warehouses: - child_warehouses = frappe.db.get_descendants('Warehouse', row.get("warehouse")) - if child_warehouses: - warehouse_list.extend(child_warehouses) - else: - warehouse_list.append(row.get("warehouse")) - - if warehouse_list: - warehouses = list(set(warehouse_list)) - - if doc.get("for_warehouse") and doc.get("for_warehouse") in warehouses: + if doc.get("for_warehouse") and not get_parent_warehouse_data and doc.get("for_warehouse") in warehouses: warehouses.remove(doc.get("for_warehouse")) - warehouse_list = None - doc['mr_items'] = [] po_items = doc.get('po_items') if doc.get('po_items') else doc.get('items') @@ -680,6 +787,9 @@ def get_items_for_material_requests(doc, warehouses=None): so_item_details = frappe._dict() for data in po_items: + if not data.get("include_exploded_items") and doc.get("sub_assembly_items"): + data["include_exploded_items"] = 1 + planned_qty = data.get('required_qty') or data.get('planned_qty') ignore_existing_ordered_qty = data.get('ignore_existing_ordered_qty') or ignore_existing_ordered_qty warehouse = doc.get('for_warehouse') @@ -754,7 +864,7 @@ def get_items_for_material_requests(doc, warehouses=None): if items: mr_items.append(items) - if not ignore_existing_ordered_qty and warehouses: + if (not ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses: new_mr_items = [] for item in mr_items: get_materials_from_other_locations(item, warehouses, new_mr_items, company) @@ -811,23 +921,28 @@ def get_item_data(item_code): # "description": item_details.get("description") } -def get_sub_assembly_items(bom_no, bom_data, to_produce_qty): +def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): data = get_children('BOM', parent = bom_no) for d in data: if d.expandable: - key = (d.name, d.value) - if key not in bom_data: - bom_data.setdefault(key, { - 'stock_qty': 0, - 'description': d.description, - 'production_item': d.item_code, - 'item_name': d.item_name, - 'stock_uom': d.stock_uom, - 'uom': d.stock_uom, - 'bom_no': d.value - }) + parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") + bom_level = (frappe.get_cached_value("BOM", d.value, "bom_level") + if d.value else 0) - bom_item = bom_data.get(key) - bom_item["stock_qty"] += (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) + stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) + bom_data.append(frappe._dict({ + 'parent_item_code': parent_item_code, + 'description': d.description, + 'production_item': d.item_code, + 'item_name': d.item_name, + 'stock_uom': d.stock_uom, + 'uom': d.stock_uom, + 'bom_no': d.value, + 'is_sub_contracted_item': d.is_sub_contracted_item, + 'bom_level': bom_level, + 'indent': indent, + 'stock_qty': stock_qty + })) - get_sub_assembly_items(bom_item.get("bom_no"), bom_data, bom_item["stock_qty"]) + if d.value: + get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py index 09ec24a67a2..ca597f63278 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py @@ -9,5 +9,9 @@ def get_data(): 'label': _('Transactions'), 'items': ['Work Order', 'Material Request'] }, + { + 'label': _('Subcontract'), + 'items': ['Purchase Order'] + }, ] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 27335aa204b..93e6d7a97f4 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -10,7 +10,7 @@ from erpnext.stock.doctype.item.test_item import create_item from erpnext.manufacturing.doctype.production_plan.production_plan import get_sales_orders from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order -from erpnext.manufacturing.doctype.production_plan.production_plan import get_items_for_material_requests +from erpnext.manufacturing.doctype.production_plan.production_plan import get_items_for_material_requests, get_warehouse_list class TestProductionPlan(unittest.TestCase): def setUp(self): @@ -100,7 +100,7 @@ class TestProductionPlan(unittest.TestCase): def test_production_plan_sales_orders(self): item = 'Test Production Item 1' - so = make_sales_order(item_code=item, qty=5) + so = make_sales_order(item_code=item, qty=1) sales_order = so.name sales_order_item = so.items[0].name @@ -124,8 +124,8 @@ class TestProductionPlan(unittest.TestCase): wo_doc = frappe.get_doc('Work Order', work_order) wo_doc.update({ - 'wip_warehouse': '_Test Warehouse 1 - _TC', - 'fg_warehouse': '_Test Warehouse - _TC' + 'wip_warehouse': 'Work In Progress - _TC', + 'fg_warehouse': 'Finished Goods - _TC' }) wo_doc.submit() @@ -145,6 +145,58 @@ class TestProductionPlan(unittest.TestCase): self.assertEqual(sales_orders, []) + def test_production_plan_combine_items(self): + item = 'Test Production Item 1' + so = make_sales_order(item_code=item, qty=1) + + pln = frappe.new_doc('Production Plan') + pln.company = so.company + pln.get_items_from = 'Sales Order' + pln.append('sales_orders', { + 'sales_order': so.name, + 'sales_order_date': so.transaction_date, + 'customer': so.customer, + 'grand_total': so.grand_total + }) + so = make_sales_order(item_code=item, qty=2) + pln.append('sales_orders', { + 'sales_order': so.name, + 'sales_order_date': so.transaction_date, + 'customer': so.customer, + 'grand_total': so.grand_total + }) + pln.combine_items = 1 + pln.get_items() + pln.submit() + + self.assertTrue(pln.po_items[0].planned_qty, 3) + + pln.make_work_order() + work_order = frappe.db.get_value('Work Order', { + 'production_plan_item': pln.po_items[0].name, + 'production_plan': pln.name + }, 'name') + + wo_doc = frappe.get_doc('Work Order', work_order) + wo_doc.update({ + 'wip_warehouse': 'Work In Progress - _TC', + }) + + wo_doc.submit() + so_items = [] + for plan_reference in pln.prod_plan_references: + so_items.append(plan_reference.sales_order_item) + so_wo_qty = frappe.db.get_value('Sales Order Item', plan_reference.sales_order_item, 'work_order_qty') + self.assertEqual(so_wo_qty, plan_reference.qty) + + wo_doc.cancel() + for so_item in so_items: + so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty') + self.assertEqual(so_wo_qty, 0.0) + + latest_plan = frappe.get_doc('Production Plan', pln.name) + latest_plan.cancel() + def test_pp_to_mr_customer_provided(self): #Material Request from Production Plan for Customer Provided create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0) @@ -184,10 +236,10 @@ class TestProductionPlan(unittest.TestCase): pln.append("po_items", { "item_code": item_code, "bom_no": frappe.db.get_value('BOM', {'item': "Test BOM 1"}), - "planned_qty": 3, - "make_work_order_for_sub_assembly_items": 1 + "planned_qty": 3 }) + pln.get_sub_assembly_items('In House') pln.submit() pln.make_work_order() @@ -199,6 +251,27 @@ class TestProductionPlan(unittest.TestCase): pln.cancel() frappe.delete_doc("Production Plan", pln.name) + def test_get_warehouse_list_group(self): + """Check if required warehouses are returned""" + warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]' + + warehouses = set(get_warehouse_list(warehouse_json)) + expected_warehouses = {"_Test Warehouse Group-C1 - _TC", "_Test Warehouse Group-C2 - _TC"} + + missing_warehouse = expected_warehouses - warehouses + + self.assertTrue(len(missing_warehouse) == 0, + msg=f"Following warehouses were expected {', '.join(missing_warehouse)}") + + def test_get_warehouse_list_single(self): + warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]' + + warehouses = set(get_warehouse_list(warehouse_json)) + expected_warehouses = {"_Test Scrap Warehouse - _TC", } + + self.assertEqual(warehouses, expected_warehouses) + + def create_production_plan(**args): args = frappe._dict(args) diff --git a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json index d0dce53437b..f829d57475a 100644 --- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json +++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json @@ -1,792 +1,220 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "hash", - "beta": 0, - "creation": "2013-02-22 01:27:49", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "editable_grid": 1, + "actions": [], + "autoname": "hash", + "creation": "2013-02-22 01:27:49", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "include_exploded_items", + "item_code", + "bom_no", + "column_break_6", + "planned_qty", + "warehouse", + "planned_start_date", + "section_break_9", + "pending_qty", + "ordered_qty", + "column_break_17", + "description", + "stock_uom", + "produced_qty", + "reference_section", + "sales_order", + "sales_order_item", + "column_break_19", + "material_request", + "material_request_item", + "product_bundle_item", + "item_reference" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fetch_if_empty": 0, - "fieldname": "include_exploded_items", - "fieldtype": "Check", - "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": "Include Exploded Items", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "columns": 1, + "default": "1", + "fieldname": "include_exploded_items", + "fieldtype": "Check", + "label": "Include Exploded Items" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fetch_if_empty": 0, - "fieldname": "item_code", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Code", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_code", - "oldfieldtype": "Link", - "options": "Item", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "150px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "oldfieldname": "item_code", + "oldfieldtype": "Link", + "options": "Item", + "print_width": "150px", + "reqd": 1, "width": "150px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fetch_if_empty": 0, - "fieldname": "bom_no", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "BOM No", - "length": 0, - "no_copy": 0, - "oldfieldname": "bom_no", - "oldfieldtype": "Link", - "options": "BOM", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "100px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "columns": 2, + "fieldname": "bom_no", + "fieldtype": "Link", + "in_list_view": 1, + "label": "BOM No", + "oldfieldname": "bom_no", + "oldfieldtype": "Link", + "options": "BOM", + "print_width": "100px", + "reqd": 1, "width": "100px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "planned_qty", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Planned Qty", - "length": 0, - "no_copy": 0, - "oldfieldname": "planned_qty", - "oldfieldtype": "Currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "100px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "planned_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Planned Qty", + "oldfieldname": "planned_qty", + "oldfieldtype": "Currency", + "print_width": "100px", + "reqd": 1, "width": "100px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "column_break_6", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "description": "If enabled, system will create the work order for the exploded items against which BOM is available.", - "fetch_if_empty": 0, - "fieldname": "make_work_order_for_sub_assembly_items", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Make Work Order for Sub Assembly Items", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "For Warehouse", + "options": "Warehouse" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fetch_if_empty": 0, - "fieldname": "warehouse", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "For Warehouse", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Today", + "fieldname": "planned_start_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Planned Start Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Today", - "fetch_if_empty": 0, - "fieldname": "planned_start_date", - "fieldtype": "Datetime", - "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": "Planned Start Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "section_break_9", + "fieldtype": "Section Break", + "label": "Quantity and Description" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "section_break_9", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Quantity and Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "fetch_if_empty": 0, - "fieldname": "pending_qty", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Pending Qty", - "length": 0, - "no_copy": 0, - "oldfieldname": "prevdoc_reqd_qty", - "oldfieldtype": "Currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "100px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "default": "0", + "fieldname": "pending_qty", + "fieldtype": "Float", + "label": "Pending Qty", + "oldfieldname": "prevdoc_reqd_qty", + "oldfieldtype": "Currency", + "print_width": "100px", + "read_only": 1, "width": "100px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "fetch_if_empty": 0, - "fieldname": "ordered_qty", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Ordered Qty", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "ordered_qty", + "fieldtype": "Float", + "label": "Ordered Qty", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "fetch_if_empty": 0, - "fieldname": "produced_qty", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Produced Qty", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "produced_qty", + "fieldtype": "Float", + "label": "Produced Qty", + "no_copy": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "column_break_17", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "description", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "oldfieldname": "description", - "oldfieldtype": "Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "200px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "200px", + "read_only": 1, "width": "200px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "stock_uom", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "UOM", - "length": 0, - "no_copy": 0, - "oldfieldname": "stock_uom", - "oldfieldtype": "Data", - "options": "UOM", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "80px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "UOM", + "oldfieldname": "stock_uom", + "oldfieldtype": "Data", + "options": "UOM", + "print_width": "80px", + "read_only": 1, + "reqd": 1, "width": "80px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "reference_section", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Reference", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "reference_section", + "fieldtype": "Section Break", + "label": "Reference" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "sales_order", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Sales Order", - "length": 0, - "no_copy": 0, - "oldfieldname": "source_docname", - "oldfieldtype": "Data", - "options": "Sales Order", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "oldfieldname": "source_docname", + "oldfieldtype": "Data", + "options": "Sales Order", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "sales_order_item", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Sales Order Item", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "sales_order_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Sales Order Item", + "no_copy": 1, + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "column_break_19", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "material_request", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Material Request", - "length": 0, - "no_copy": 0, - "options": "Material Request", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "material_request", + "fieldtype": "Link", + "label": "Material Request", + "options": "Material Request", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "material_request_item", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "material_request_item", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "material_request_item", + "fieldtype": "Data", + "hidden": 1, + "label": "material_request_item" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "product_bundle_item", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Product Bundle Item", - "length": 0, - "no_copy": 1, - "options": "Item", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "product_bundle_item", + "fieldtype": "Link", + "label": "Product Bundle Item", + "no_copy": 1, + "options": "Item", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "item_reference", + "fieldtype": "Data", + "hidden": 1, + "label": "Item Reference" } - ], - "has_web_view": 0, - "hide_toolbar": 0, - "idx": 1, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2019-04-08 23:09:57.199423", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "Production Plan Item", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "show_name_in_global_search": 0, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "idx": 1, + "istable": 1, + "links": [], + "modified": "2021-06-28 18:31:06.822168", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Plan Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "ASC" } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/__init__.py b/erpnext/manufacturing/doctype/production_plan_item_reference/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json new file mode 100644 index 00000000000..84dee4ad284 --- /dev/null +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json @@ -0,0 +1,52 @@ +{ + "actions": [], + "creation": "2021-04-22 10:32:58.896330", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_reference", + "sales_order", + "sales_order_item", + "qty" + ], + "fields": [ + { + "fieldname": "sales_order", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sales Order Reference", + "options": "Sales Order" + }, + { + "fieldname": "sales_order_item", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Sales Order Item" + }, + { + "fieldname": "qty", + "fieldtype": "Data", + "in_list_view": 1, + "label": "qty" + }, + { + "fieldname": "item_reference", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Reference" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-05-07 17:03:49.707487", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Plan Item Reference", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py new file mode 100644 index 00000000000..51fbc3633b1 --- /dev/null +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ProductionPlanItemReference(Document): + pass diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/__init__.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json new file mode 100644 index 00000000000..657ee35a852 --- /dev/null +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -0,0 +1,202 @@ +{ + "actions": [], + "creation": "2020-12-27 16:08:36.127199", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "production_item", + "item_name", + "fg_warehouse", + "parent_item_code", + "schedule_date", + "column_break_3", + "qty", + "bom_no", + "bom_level", + "type_of_manufacturing", + "supplier", + "work_order_details_section", + "work_order", + "purchase_order", + "production_plan_item", + "column_break_7", + "produced_qty", + "received_qty", + "indent", + "section_break_19", + "uom", + "stock_uom", + "column_break_22", + "description" + ], + "fields": [ + { + "fetch_from": "sub_assembly_item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.type_of_manufacturing == \"In House\"", + "fieldname": "work_order_details_section", + "fieldtype": "Section Break", + "label": "Reference" + }, + { + "fieldname": "work_order", + "fieldtype": "Link", + "label": "Work Order", + "options": "Work Order", + "read_only": 1 + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Required Qty", + "read_only": 1 + }, + { + "fieldname": "purchase_order", + "fieldtype": "Link", + "label": "Purchase Order", + "options": "Purchase Order", + "read_only": 1 + }, + { + "fieldname": "received_qty", + "fieldtype": "Float", + "label": "Received Qty" + }, + { + "fieldname": "bom_no", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Bom No", + "options": "BOM" + }, + { + "fieldname": "production_plan_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Item", + "read_only": 1 + }, + { + "fieldname": "parent_item_code", + "fieldtype": "Link", + "label": "Finished Good", + "options": "Item", + "read_only": 1 + }, + { + "columns": 1, + "fetch_from": "bom_no.bom_level", + "fieldname": "bom_level", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Level (BOM)", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_19", + "fieldtype": "Section Break", + "label": "Item Details" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "description", + "read_only": 1 + }, + { + "fieldname": "production_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sub Assembly Item Code", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "indent", + "fieldtype": "Int", + "label": "Indent" + }, + { + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "label": "Target Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "produced_qty", + "fieldtype": "Data", + "label": "Produced Quantity", + "read_only": 1 + }, + { + "default": "In House", + "fieldname": "type_of_manufacturing", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Manufacturing Type", + "options": "In House\nSubcontract" + }, + { + "fieldname": "supplier", + "fieldtype": "Link", + "label": "Supplier", + "mandatory_depends_on": "eval:doc.type_of_manufacturing == 'Subcontract'", + "options": "Supplier" + }, + { + "fieldname": "schedule_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Schedule Date" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-06-28 20:10:56.296410", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Plan Sub Assembly Item", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py new file mode 100644 index 00000000000..6850a2eb4ed --- /dev/null +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ProductionPlanSubAssemblyItem(Document): + pass diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py index 8312d7436c2..ece0db717a2 100644 --- a/erpnext/manufacturing/doctype/routing/routing.py +++ b/erpnext/manufacturing/doctype/routing/routing.py @@ -4,14 +4,24 @@ from __future__ import unicode_literals import frappe -from frappe.utils import cint +from frappe.utils import cint, flt from frappe import _ from frappe.model.document import Document class Routing(Document): def validate(self): + self.calculate_operating_cost() self.set_routing_id() + def on_update(self): + self.calculate_operating_cost() + + def calculate_operating_cost(self): + for operation in self.operations: + if not operation.hour_rate: + operation.hour_rate = frappe.db.get_value("Workstation", operation.workstation, 'hour_rate') + operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, 2) + def set_routing_id(self): sequence_id = 0 for row in self.operations: @@ -21,4 +31,4 @@ class Routing(Document): frappe.throw(_("At row #{0}: the sequence id {1} cannot be less than previous row sequence id {2}") .format(row.idx, row.sequence_id, sequence_id)) - sequence_id = row.sequence_id \ No newline at end of file + sequence_id = row.sequence_id diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index 6a38dcfa030..92f26946ab7 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -7,9 +7,7 @@ import unittest import frappe from frappe.test_runner import make_test_records from erpnext.stock.doctype.item.test_item import make_item -from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError -from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record class TestRouting(unittest.TestCase): @@ -48,7 +46,53 @@ class TestRouting(unittest.TestCase): wo_doc.cancel() wo_doc.delete() + def test_update_bom_operation_time(self): + operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "hour_rate_rent": 300, + "hour_rate_labour": 750 , + "time_in_mins": 30 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation B", + "hour_rate_labour": 200, + "hour_rate_rent": 1000, + "time_in_mins": 20 + } + ] + + test_routing_operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "time_in_mins": 30 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation A", + "time_in_mins": 20 + } + ] + setup_operations(operations) + routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations) + bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency = 'INR') + self.assertEqual(routing_doc.operations[0].time_in_mins, 30) + self.assertEqual(routing_doc.operations[1].time_in_mins, 20) + routing_doc.operations[0].time_in_mins = 90 + routing_doc.operations[1].time_in_mins = 42.2 + routing_doc.save() + bom_doc.update_cost() + bom_doc.reload() + self.assertEqual(bom_doc.operations[0].time_in_mins, 90) + self.assertEqual(bom_doc.operations[1].time_in_mins, 42.2) + + def setup_operations(rows): + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + from erpnext.manufacturing.doctype.operation.test_operation import make_operation for row in rows: make_workstation(row) make_operation(row) @@ -61,12 +105,14 @@ def create_routing(**args): if not args.do_not_save: try: - for operation in args.operations: - doc.append("operations", operation) - doc.insert() except frappe.DuplicateEntryError: doc = frappe.get_doc("Routing", args.routing_name) + doc.delete_key('operations') + for operation in args.operations: + doc.append("operations", operation) + + doc.save() return doc @@ -91,7 +137,7 @@ def setup_bom(**args): name = frappe.db.get_value('BOM', {'item': args.item_code}, 'name') if not name: bom_doc = make_bom(item = args.item_code, raw_materials = args.get("raw_materials"), - routing = args.routing, with_operations=1) + routing = args.routing, with_operations=1, currency = args.currency) else: bom_doc = frappe.get_doc("BOM", name) diff --git a/erpnext/manufacturing/doctype/sub_operation/__init__.py b/erpnext/manufacturing/doctype/sub_operation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/sub_operation/sub_operation.js b/erpnext/manufacturing/doctype/sub_operation/sub_operation.js new file mode 100644 index 00000000000..be9db6a4089 --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/sub_operation.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Sub Operation', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/manufacturing/doctype/sub_operation/sub_operation.json b/erpnext/manufacturing/doctype/sub_operation/sub_operation.json new file mode 100644 index 00000000000..10cee32398a --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/sub_operation.json @@ -0,0 +1,52 @@ +{ + "actions": [], + "creation": "2020-12-07 15:39:47.488519", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "operation", + "time_in_mins", + "column_break_5", + "description" + ], + "fields": [ + { + "fieldname": "operation", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Operation", + "options": "Operation" + }, + { + "default": "0", + "description": "Time in mins", + "fieldname": "time_in_mins", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Operation Time" + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-07-15 16:39:41.635362", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Sub Operation", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/sub_operation/sub_operation.py b/erpnext/manufacturing/doctype/sub_operation/sub_operation.py new file mode 100644 index 00000000000..f4b27758e9d --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/sub_operation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class SubOperation(Document): + pass diff --git a/erpnext/manufacturing/doctype/sub_operation/test_sub_operation.py b/erpnext/manufacturing/doctype/sub_operation/test_sub_operation.py new file mode 100644 index 00000000000..d3410ca3120 --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/test_sub_operation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestSubOperation(unittest.TestCase): + pass diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index cb1ee92196f..bf1ccb71594 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -389,17 +389,12 @@ class TestWorkOrder(unittest.TestCase): ste.submit() stock_entries.append(ste) - job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}) + job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}, order_by='creation asc') self.assertEqual(len(job_cards), len(bom.operations)) for i, job_card in enumerate(job_cards): doc = frappe.get_doc("Job Card", job_card) - doc.append("time_logs", { - "from_time": add_to_date(None, i), - "hours": 1, - "to_time": add_to_date(None, i + 1), - "completed_qty": doc.for_quantity - }) + doc.time_logs[0].completed_qty = 1 doc.submit() ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) @@ -518,6 +513,60 @@ class TestWorkOrder(unittest.TestCase): work_order1.save() self.assertEqual(work_order1.operations[0].time_in_mins, 40.0) + def test_batch_size_for_fg_item(self): + fg_item = "Test Batch Size Item For BOM 3" + rm1 = "Test Batch Size Item RM 1 For BOM 3" + + frappe.db.set_value('Manufacturing Settings', None, 'make_serial_no_batch_from_work_order', 0) + for item in ["Test Batch Size Item For BOM 3", "Test Batch Size Item RM 1 For BOM 3"]: + item_args = { + "include_item_in_manufacturing": 1, + "is_stock_item": 1 + } + + if item == fg_item: + item_args['has_batch_no'] = 1 + item_args['create_new_batch'] = 1 + item_args['batch_number_series'] = 'TBSI3.#####' + + make_item(item, item_args) + + bom_name = frappe.db.get_value("BOM", + {"item": fg_item, "is_active": 1, "with_operations": 1}, "name") + + if not bom_name: + bom = make_bom(item=fg_item, rate=1000, raw_materials = [rm1], do_not_save=True) + bom.save() + bom.submit() + bom_name = bom.name + + work_order = make_wo_order_test_record(item=fg_item, skip_transfer=True, planned_start_date=now(), qty=1) + ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) + for row in ste1.get('items'): + if row.is_finished_item: + self.assertEqual(row.item_code, fg_item) + + work_order = make_wo_order_test_record(item=fg_item, skip_transfer=True, planned_start_date=now(), qty=1) + frappe.db.set_value('Manufacturing Settings', None, 'make_serial_no_batch_from_work_order', 1) + ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) + for row in ste1.get('items'): + if row.is_finished_item: + self.assertEqual(row.item_code, fg_item) + + work_order = make_wo_order_test_record(item=fg_item, skip_transfer=True, planned_start_date=now(), + qty=30, do_not_save = True) + work_order.batch_size = 10 + work_order.insert() + work_order.submit() + self.assertEqual(work_order.has_batch_no, 1) + ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 30)) + for row in ste1.get('items'): + if row.is_finished_item: + self.assertEqual(row.item_code, fg_item) + self.assertEqual(row.qty, 10) + + frappe.db.set_value('Manufacturing Settings', None, 'make_serial_no_batch_from_work_order', 0) + def test_partial_material_consumption(self): frappe.db.set_value("Manufacturing Settings", None, "material_consumption", 1) wo_order = make_wo_order_test_record(planned_start_date=now(), qty=4) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 3e5a72db9a7..512048512ed 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -141,8 +141,7 @@ frappe.ui.form.on("Work Order", { } if (frm.doc.docstatus === 1 - && frm.doc.operations && frm.doc.operations.length - && frm.doc.qty != frm.doc.material_transferred_for_manufacturing) { + && frm.doc.operations && frm.doc.operations.length) { const not_completed = frm.doc.operations.filter(d => { if(d.status != 'Completed') { @@ -190,35 +189,41 @@ frappe.ui.form.on("Work Order", { const dialog = frappe.prompt({fieldname: 'operations', fieldtype: 'Table', label: __('Operations'), fields: [ { - fieldtype:'Link', - fieldname:'operation', + fieldtype: 'Link', + fieldname: 'operation', label: __('Operation'), - read_only:1, - in_list_view:1 + read_only: 1, + in_list_view: 1 }, { - fieldtype:'Link', - fieldname:'workstation', + fieldtype: 'Link', + fieldname: 'workstation', label: __('Workstation'), - read_only:1, - in_list_view:1 + read_only: 1, + in_list_view: 1 }, { - fieldtype:'Data', - fieldname:'name', + fieldtype: 'Data', + fieldname: 'name', label: __('Operation Id') }, { - fieldtype:'Float', - fieldname:'pending_qty', + fieldtype: 'Float', + fieldname: 'pending_qty', label: __('Pending Qty'), }, { - fieldtype:'Float', - fieldname:'qty', + fieldtype: 'Float', + fieldname: 'qty', label: __('Quantity to Manufacture'), - read_only:0, - in_list_view:1, + read_only: 0, + in_list_view: 1, + }, + { + fieldtype: 'Float', + fieldname: 'batch_size', + label: __('Batch Size'), + read_only: 1 }, ], data: operations_data, @@ -229,9 +234,13 @@ frappe.ui.form.on("Work Order", { }, function(data) { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.work_order.make_job_card", + freeze: true, args: { work_order: frm.doc.name, operations: data.operations, + }, + callback: function() { + frm.reload_doc(); } }); }, __("Job Card"), __("Create")); @@ -243,13 +252,16 @@ frappe.ui.form.on("Work Order", { if(data.completed_qty != frm.doc.qty) { pending_qty = frm.doc.qty - flt(data.completed_qty); - dialog.fields_dict.operations.df.data.push({ - 'name': data.name, - 'operation': data.operation, - 'workstation': data.workstation, - 'qty': pending_qty, - 'pending_qty': pending_qty, - }); + if (pending_qty) { + dialog.fields_dict.operations.df.data.push({ + 'name': data.name, + 'operation': data.operation, + 'workstation': data.workstation, + 'batch_size': data.batch_size, + 'qty': pending_qty, + 'pending_qty': pending_qty + }); + } } }); dialog.fields_dict.operations.grid.refresh(); @@ -704,6 +716,8 @@ erpnext.work_order = { stop_work_order: function(frm, status) { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.work_order.stop_unstop", + freeze: true, + freeze_message: __("Updating Work Order status"), args: { work_order: frm.doc.name, status: status diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index cd9edeeea83..3b56854aaf3 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -21,6 +21,12 @@ "produced_qty", "sales_order", "project", + "serial_no_and_batch_for_finished_good_section", + "has_serial_no", + "has_batch_no", + "column_break_17", + "serial_no", + "batch_size", "settings_section", "allow_alternative_item", "use_multi_level_bom", @@ -52,16 +58,22 @@ "actual_operating_cost", "additional_operating_cost", "column_break_24", + "corrective_operation_cost", "total_operating_cost", "more_info", "description", "stock_uom", "column_break2", + "references_section", "material_request", "material_request_item", "sales_order_item", + "column_break_61", "production_plan", "production_plan_item", + "production_plan_sub_assembly_item", + "parent_work_order", + "bom_level", "product_bundle_item", "amended_from" ], @@ -488,17 +500,77 @@ "fieldtype": "Float", "label": "Lead Time", "read_only": 1 - } + }, + { + "collapsible": 1, + "depends_on": "eval:!doc.__islocal", + "fieldname": "serial_no_and_batch_for_finished_good_section", + "fieldtype": "Section Break", + "label": "Serial No and Batch for Finished Good" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fetch_from": "production_item.has_serial_no", + "fieldname": "has_serial_no", + "fieldtype": "Check", + "label": "Has Serial No", + "read_only": 1 + }, + { + "default": "0", + "fetch_from": "production_item.has_batch_no", + "fieldname": "has_batch_no", + "fieldtype": "Check", + "label": "Has Batch No", + "read_only": 1 + }, + { + "depends_on": "has_serial_no", + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial Nos", + "no_copy": 1 + }, + { + "default": "0", + "depends_on": "has_batch_no", + "fieldname": "batch_size", + "fieldtype": "Float", + "label": "Batch Size" + }, + { + "allow_on_submit": 1, + "description": "From Corrective Job Card", + "fieldname": "corrective_operation_cost", + "fieldtype": "Currency", + "label": "Corrective Operation Cost", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "production_plan_sub_assembly_item", + "fieldtype": "Data", + "label": "Production Plan Sub-assembly Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + } ], "icon": "fa fa-cogs", "idx": 1, "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-03-16 13:27:51.116484", + "modified": "2021-06-28 16:19:14.902699", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", + "nsm_parent_field": "parent_work_order", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8507f5eb34c..282b5d0afe4 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1,7 +1,6 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals import frappe import json import math @@ -19,18 +18,17 @@ from frappe.utils.csvutils import getlink from erpnext.stock.utils import get_bin, validate_warehouse_company, get_latest_stock_qty from erpnext.utilities.transaction_base import validate_uom_is_integer from frappe.model.mapper import get_mapped_doc +from erpnext.stock.doctype.batch.batch import make_batch +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, get_auto_serial_nos, auto_make_serial_nos class OverProductionError(frappe.ValidationError): pass class CapacityError(frappe.ValidationError): pass class StockOverProductionError(frappe.ValidationError): pass class OperationTooLongError(frappe.ValidationError): pass class ItemHasVariantError(frappe.ValidationError): pass +class SerialNoQtyError(frappe.ValidationError): + pass -from six import string_types - -form_grid_templates = { - "operations": "templates/form_grid/work_order_grid.html" -} class WorkOrder(Document): def onload(self): @@ -127,7 +125,9 @@ class WorkOrder(Document): variable_cost = self.actual_operating_cost if self.actual_operating_cost \ else self.planned_operating_cost - self.total_operating_cost = flt(self.additional_operating_cost) + flt(variable_cost) + + self.total_operating_cost = (flt(self.additional_operating_cost) + + flt(variable_cost) + flt(self.corrective_operation_cost)) def validate_work_order_against_so(self): # already ordered qty @@ -235,13 +235,20 @@ class WorkOrder(Document): production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item) + def before_submit(self): + self.create_serial_no_batch_no() + def on_submit(self): - if not self.wip_warehouse: + if not self.wip_warehouse and not self.skip_transfer: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) if not self.fg_warehouse: frappe.throw(_("For Warehouse is required before Submit")) - self.update_work_order_qty_in_so() + if self.production_plan and frappe.db.exists('Production Plan Item Reference',{'parent':self.production_plan}): + self.update_work_order_qty_in_combined_so() + else: + self.update_work_order_qty_in_so() + self.update_reserved_qty_for_production() self.update_completed_qty_in_material_request() self.update_planned_qty() @@ -250,14 +257,82 @@ class WorkOrder(Document): def on_cancel(self): self.validate_cancel() - frappe.db.set(self,'status', 'Cancelled') - self.update_work_order_qty_in_so() + + if self.production_plan and frappe.db.exists('Production Plan Item Reference',{'parent':self.production_plan}): + self.update_work_order_qty_in_combined_so() + else: + self.update_work_order_qty_in_so() + self.delete_job_card() self.update_completed_qty_in_material_request() self.update_planned_qty() self.update_ordered_qty() self.update_reserved_qty_for_production() + self.delete_auto_created_batch_and_serial_no() + + def create_serial_no_batch_no(self): + if not (self.has_serial_no or self.has_batch_no): + return + + if not cint(frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")): + return + + if self.has_batch_no: + self.create_batch_for_finished_good() + + args = { + "item_code": self.production_item, + "work_order": self.name + } + + if self.has_serial_no: + self.make_serial_nos(args) + + def create_batch_for_finished_good(self): + total_qty = self.qty + if not self.batch_size: + self.batch_size = total_qty + + while total_qty > 0: + qty = self.batch_size + if self.batch_size >= total_qty: + qty = total_qty + + if total_qty > self.batch_size: + total_qty -= self.batch_size + else: + qty = total_qty + total_qty = 0 + + make_batch(frappe._dict({ + "item": self.production_item, + "qty_to_produce": qty, + "reference_doctype": self.doctype, + "reference_name": self.name + })) + + def delete_auto_created_batch_and_serial_no(self): + for row in frappe.get_all("Serial No", filters = {"work_order": self.name}): + frappe.delete_doc("Serial No", row.name) + self.db_set("serial_no", "") + + for row in frappe.get_all("Batch", filters = {"reference_name": self.name}): + frappe.delete_doc("Batch", row.name) + + def make_serial_nos(self, args): + serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series") + if serial_no_series: + self.serial_no = get_auto_serial_nos(serial_no_series, self.qty) + + if self.serial_no: + args.update({"serial_no": self.serial_no, "actual_qty": self.qty}) + auto_make_serial_nos(args) + + serial_nos_length = len(get_serial_nos(self.serial_no)) + if serial_nos_length != self.qty: + frappe.throw(_("{0} Serial Numbers required for Item {1}. You have provided {2}.") + .format(self.qty, self.production_item, serial_nos_length), SerialNoQtyError) def create_job_card(self): manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings") @@ -265,32 +340,40 @@ class WorkOrder(Document): enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning) plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30 - for i, row in enumerate(self.operations): - self.set_operation_start_end_time(i, row) - - if not row.workstation: - frappe.throw(_("Row {0}: select the workstation against the operation {1}") - .format(row.idx, row.operation)) - - original_start_time = row.planned_start_time - job_card_doc = create_job_card(self, row, - enable_capacity_planning=enable_capacity_planning, auto_create=True) - - if enable_capacity_planning and job_card_doc: - row.planned_start_time = job_card_doc.time_logs[-1].from_time - row.planned_end_time = job_card_doc.time_logs[-1].to_time - - if date_diff(row.planned_start_time, original_start_time) > plan_days: - frappe.message_log.pop() - frappe.throw(_("Unable to find the time slot in the next {0} days for the operation {1}.") - .format(plan_days, row.operation), CapacityError) - - row.db_update() + for index, row in enumerate(self.operations): + qty = self.qty + while qty > 0: + qty = split_qty_based_on_batch_size(self, row, qty) + if row.job_card_qty > 0: + self.prepare_data_for_job_card(row, index, + plan_days, enable_capacity_planning) planned_end_date = self.operations and self.operations[-1].planned_end_time if planned_end_date: self.db_set("planned_end_date", planned_end_date) + def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning): + self.set_operation_start_end_time(index, row) + + if not row.workstation: + frappe.throw(_("Row {0}: select the workstation against the operation {1}") + .format(row.idx, row.operation)) + + original_start_time = row.planned_start_time + job_card_doc = create_job_card(self, row, auto_create=True, + enable_capacity_planning=enable_capacity_planning) + + if enable_capacity_planning and job_card_doc: + row.planned_start_time = job_card_doc.time_logs[-1].from_time + row.planned_end_time = job_card_doc.time_logs[-1].to_time + + if date_diff(row.planned_start_time, original_start_time) > plan_days: + frappe.message_log.pop() + frappe.throw(_("Unable to find the time slot in the next {0} days for the operation {1}.") + .format(plan_days, row.operation), CapacityError) + + row.db_update() + def set_operation_start_end_time(self, idx, row): """Set start and end time for given operation. If first operation, set start as `planned_start_date`, else add time diff to end time of earlier operation.""" @@ -358,52 +441,73 @@ class WorkOrder(Document): frappe.db.set_value('Sales Order Item', self.sales_order_item, 'work_order_qty', flt(work_order_qty/total_bundle_qty, 2)) + def update_work_order_qty_in_combined_so(self): + total_bundle_qty = 1 + if self.product_bundle_item: + total_bundle_qty = frappe.db.sql(""" select sum(qty) from + `tabProduct Bundle Item` where parent = %s""", (frappe.db.escape(self.product_bundle_item)))[0][0] + + if not total_bundle_qty: + # product bundle is 0 (product bundle allows 0 qty for items) + total_bundle_qty = 1 + + prod_plan = frappe.get_doc('Production Plan', self.production_plan) + item_reference = frappe.get_value('Production Plan Item', self.production_plan_item, 'sales_order_item') + + for plan_reference in prod_plan.prod_plan_references: + work_order_qty = 0.0 + if plan_reference.item_reference == item_reference: + if self.docstatus == 1: + work_order_qty = flt(plan_reference.qty) / total_bundle_qty + frappe.db.set_value('Sales Order Item', + plan_reference.sales_order_item, 'work_order_qty', work_order_qty) + def update_completed_qty_in_material_request(self): if self.material_request: frappe.get_doc("Material Request", self.material_request).update_completed_qty([self.material_request_item]) def set_work_order_operations(self): """Fetch operations from BOM and set in 'Work Order'""" - self.set('operations', []) - if not self.bom_no: + def _get_operations(bom_no, qty=1): + return frappe.db.sql( + f"""select + operation, description, workstation, idx, + base_hour_rate as hour_rate, time_in_mins * {qty} as time_in_mins, + "Pending" as status, parent as bom, batch_size, sequence_id + from + `tabBOM Operation` + where + parent = %s order by idx + """, bom_no, as_dict=1) + + + self.set('operations', []) + if not self.bom_no or not frappe.get_cached_value('BOM', self.bom_no, 'with_operations'): return - if self.use_multi_level_bom: - bom_list = frappe.get_doc("BOM", self.bom_no).traverse_tree() - else: - bom_list = [self.bom_no] + operations = [] - operations = frappe.db.sql(""" - select - operation, description, workstation, idx, - base_hour_rate as hour_rate, time_in_mins, - "Pending" as status, parent as bom, batch_size, sequence_id - from - `tabBOM Operation` - where - parent in (%s) order by idx - """ % ", ".join(["%s"]*len(bom_list)), tuple(bom_list), as_dict=1) + if self.use_multi_level_bom: + bom_tree = frappe.get_doc("BOM", self.bom_no).get_tree_representation() + bom_traversal = reversed(bom_tree.level_order_traversal()) + + for node in bom_traversal: + if node.is_bom: + operations.extend(_get_operations(node.name, qty=node.exploded_qty)) + + bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") + operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty)) + + for correct_index, operation in enumerate(operations, start=1): + operation.idx = correct_index self.set('operations', operations) - - if self.use_multi_level_bom and self.get('operations') and self.get('items'): - raw_material_operations = [d.operation for d in self.get('items')] - operations = [d.operation for d in self.get('operations')] - - for operation in raw_material_operations: - if operation not in operations: - self.append('operations', { - 'operation': operation - }) - self.calculate_time() def calculate_time(self): - bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") - for d in self.get("operations"): - d.time_in_mins = flt(d.time_in_mins) / flt(bom_qty) * (flt(self.qty) / flt(d.batch_size)) + d.time_in_mins = flt(d.time_in_mins) * (flt(self.qty) / flt(d.batch_size)) self.calculate_operating_cost() @@ -485,6 +589,7 @@ class WorkOrder(Document): def validate_operation_time(self): for d in self.operations: if not d.time_in_mins > 0: + print(self.bom_no, self.production_item) frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation)) def update_required_items(self): @@ -550,7 +655,7 @@ class WorkOrder(Document): for item in sorted(item_dict.values(), key=lambda d: d['idx'] or 9999): self.append('required_items', { 'rate': item.rate, - 'amount': item.amount, + 'amount': item.rate * item.qty, 'operation': item.operation or operation, 'item_code': item.item_code, 'item_name': item.item_name, @@ -640,6 +745,17 @@ class WorkOrder(Document): bom.set_bom_material_details() return bom + def update_batch_produced_qty(self, stock_entry_doc): + if not cint(frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")): + return + + for row in stock_entry_doc.items: + if row.batch_no and (row.is_finished_item or row.is_scrap_item): + qty = frappe.get_all("Stock Entry Detail", filters = {"batch_no": row.batch_no, "docstatus": 1}, + or_filters= {"is_finished_item": 1, "is_scrap_item": 1}, fields = ["sum(qty)"], as_list=1)[0][0] + + frappe.db.set_value("Batch", row.batch_no, "produced_qty", flt(qty)) + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_bom_operations(doctype, txt, searchfield, start, page_len, filters): @@ -717,7 +833,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None): return wo_doc def add_variant_item(variant_items, wo_doc, bom_no, table_name="items"): - if isinstance(variant_items, string_types): + if isinstance(variant_items, str): variant_items = json.loads(variant_items) for item in variant_items: @@ -797,6 +913,7 @@ def make_stock_entry(work_order_id, purpose, qty=None): stock_entry.set_stock_entry_type() stock_entry.get_items() + stock_entry.set_serial_no_batch_for_finished_good() return stock_entry.as_dict() @frappe.whitelist() @@ -838,13 +955,47 @@ def query_sales_order(production_item): @frappe.whitelist() def make_job_card(work_order, operations): - if isinstance(operations, string_types): + if isinstance(operations, str): operations = json.loads(operations) work_order = frappe.get_doc('Work Order', work_order) for row in operations: + row = frappe._dict(row) validate_operation_data(row) - create_job_card(work_order, row, row.get("qty"), auto_create=True) + qty = row.get("qty") + while qty > 0: + qty = split_qty_based_on_batch_size(work_order, row, qty) + if row.job_card_qty > 0: + create_job_card(work_order, row, auto_create=True) + +def split_qty_based_on_batch_size(wo_doc, row, qty): + if not cint(frappe.db.get_value("Operation", + row.operation, "create_job_card_based_on_batch_size")): + row.batch_size = row.get("qty") or wo_doc.qty + + row.job_card_qty = row.batch_size + if row.batch_size and qty >= row.batch_size: + qty -= row.batch_size + elif qty > 0: + row.job_card_qty = qty + qty = 0 + + get_serial_nos_for_job_card(row, wo_doc) + + return qty + +def get_serial_nos_for_job_card(row, wo_doc): + if not wo_doc.serial_no: + return + + serial_nos = get_serial_nos(wo_doc.serial_no) + used_serial_nos = [] + for d in frappe.get_all('Job Card', fields=['serial_no'], + filters={'docstatus': ('<', 2), 'work_order': wo_doc.name, 'operation_id': row.name}): + 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]) def validate_operation_data(row): if row.get("qty") <= 0: @@ -863,20 +1014,22 @@ def validate_operation_data(row): ) ) -def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto_create=False): +def create_job_card(work_order, row, enable_capacity_planning=False, auto_create=False): doc = frappe.new_doc("Job Card") doc.update({ 'work_order': work_order.name, 'operation': row.get("operation"), 'workstation': row.get("workstation"), 'posting_date': nowdate(), - 'for_quantity': qty or work_order.get('qty', 0), + 'for_quantity': row.job_card_qty or work_order.get('qty', 0), 'operation_id': row.get("name"), 'bom_no': work_order.bom_no, 'project': work_order.project, 'company': work_order.company, 'sequence_id': row.get("sequence_id"), - 'wip_warehouse': work_order.wip_warehouse + 'wip_warehouse': work_order.wip_warehouse, + 'hour_rate': row.get("hour_rate"), + 'serial_no': row.get("serial_no") }) if work_order.transfer_material_against == 'Job Card' and not work_order.skip_transfer: diff --git a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py index 87c090f99c3..9aa0715e7ff 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py +++ b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py @@ -4,10 +4,17 @@ from frappe import _ def get_data(): return { 'fieldname': 'work_order', + 'non_standard_fieldnames': { + 'Batch': 'reference_name' + }, 'transactions': [ { 'label': _('Transactions'), 'items': ['Stock Entry', 'Job Card', 'Pick List'] + }, + { + 'label': _('Reference'), + 'items': ['Serial No', 'Batch'] } ] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/work_order_preview.html b/erpnext/manufacturing/doctype/work_order/work_order_preview.html new file mode 100644 index 00000000000..a4bf93edef1 --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/work_order_preview.html @@ -0,0 +1,33 @@ +

    +
    +
    + {% if data.image %} +
    + +
    + {% endif %} +
    +
    +
    + Status {{ data.status }} +
    +
    + Qty to Produce {{ data.qty }} +
    +
    + Produced Qty {{ data.produced_qty }} +
    +
    +

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

    +
    +
    +
    \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 8c5cde9a13c..f7b8787a0b3 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -2,14 +2,14 @@ "actions": [], "creation": "2014-10-16 14:35:41.950175", "doctype": "DocType", - "editable_grid": 1, "engine": "InnoDB", "field_order": [ "details", "operation", "bom", - "sequence_id", + "column_break_4", "description", + "sequence_id", "col_break1", "completed_qty", "status", @@ -48,6 +48,7 @@ { "fieldname": "bom", "fieldtype": "Link", + "in_list_view": 1, "label": "BOM", "no_copy": 1, "options": "BOM", @@ -67,6 +68,7 @@ "fieldtype": "Column Break" }, { + "columns": 1, "description": "Operation completed for how many finished goods?", "fieldname": "completed_qty", "fieldtype": "Float", @@ -76,6 +78,7 @@ "read_only": 1 }, { + "columns": 1, "default": "Pending", "fieldname": "status", "fieldtype": "Select", @@ -118,6 +121,7 @@ "fieldtype": "Column Break" }, { + "columns": 1, "description": "in Minutes", "fieldname": "time_in_mins", "fieldtype": "Float", @@ -195,12 +199,16 @@ "label": "Sequence ID", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-14 12:58:49.241252", + "modified": "2021-06-24 14:36:12.835543", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", @@ -209,4 +217,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index c6699bee48c..9b73aca6010 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -1,16 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt from __future__ import unicode_literals +from erpnext.manufacturing.doctype.operation.test_operation import make_operation import frappe import unittest from erpnext.manufacturing.doctype.workstation.workstation import check_if_within_operating_hours, NotInWorkingHoursError, WorkstationHolidayError +from erpnext.manufacturing.doctype.routing.test_routing import setup_bom, create_routing +from frappe.test_runner import make_test_records test_dependencies = ["Warehouse"] test_records = frappe.get_test_records('Workstation') +make_test_records('Workstation') class TestWorkstation(unittest.TestCase): - def test_validate_timings(self): check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00") check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00") @@ -21,6 +24,58 @@ class TestWorkstation(unittest.TestCase): self.assertRaises(WorkstationHolidayError, check_if_within_operating_hours, "_Test Workstation 1", "Operation 1", "2013-02-01 10:00:00", "2013-02-02 20:00:00") + def test_update_bom_operation_rate(self): + operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "hour_rate_rent": 300, + "time_in_mins": 60 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation B", + "hour_rate_rent": 1000, + "time_in_mins": 60 + } + ] + + for row in operations: + make_workstation(row) + make_operation(row) + + test_routing_operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "time_in_mins": 60 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation A", + "time_in_mins": 60 + } + ] + routing_doc = create_routing(routing_name = "Routing Test", operations=test_routing_operations) + bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR") + w1 = frappe.get_doc("Workstation", "_Test Workstation A") + #resets values + w1.hour_rate_rent = 300 + w1.hour_rate_labour = 0 + w1.save() + bom_doc.update_cost() + bom_doc.reload() + self.assertEqual(w1.hour_rate, 300) + self.assertEqual(bom_doc.operations[0].hour_rate, 300) + w1.hour_rate_rent = 250 + w1.save() + #updating after setting new rates in workstations + bom_doc.update_cost() + bom_doc.reload() + self.assertEqual(w1.hour_rate, 250) + self.assertEqual(bom_doc.operations[0].hour_rate, 250) + self.assertEqual(bom_doc.operations[1].hour_rate, 250) + def make_workstation(*args, **kwargs): args = args if args else kwargs if isinstance(args, tuple): @@ -34,9 +89,10 @@ def make_workstation(*args, **kwargs): "doctype": "Workstation", "workstation_name": workstation_name }) - + doc.hour_rate_rent = args.get("hour_rate_rent") + doc.hour_rate_labour = args.get("hour_rate_labour") doc.insert() return doc except frappe.DuplicateEntryError: - return frappe.get_doc("Workstation", workstation_name) \ No newline at end of file + return frappe.get_doc("Workstation", workstation_name) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 3512e590458..f4483f75472 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -39,7 +39,8 @@ class Workstation(Document): def update_bom_operation(self): bom_list = frappe.db.sql("""select DISTINCT parent from `tabBOM Operation` - where workstation = %s""", self.name) + where workstation = %s and parenttype = 'routing' """, self.name) + for bom_no in bom_list: frappe.db.sql("""update `tabBOM Operation` set hour_rate = %s where parent = %s and workstation = %s""", @@ -71,7 +72,7 @@ def check_if_within_operating_hours(workstation, operation, from_datetime, to_da def is_within_operating_hours(workstation, operation, from_datetime, to_datetime): operation_length = time_diff_in_seconds(to_datetime, from_datetime) workstation = frappe.get_doc("Workstation", workstation) - + if not workstation.working_hours: return diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index 48907adc5f3..858b5546b02 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -20,17 +20,20 @@ def get_exploded_items(bom, data, indent=0, qty=1): fields= ['qty','bom_no','qty','scrap','item_code','item_name','description','uom']) for item in exploded_items: + print(item.bom_no, indent) item["indent"] = indent data.append({ 'item_code': item.item_code, 'item_name': item.item_name, 'indent': indent, + 'bom_level': (frappe.get_cached_value("BOM", item.bom_no, "bom_level") + if item.bom_no else ""), 'bom': item.bom_no, 'qty': item.qty * qty, 'uom': item.uom, 'description': item.description, 'scrap': item.scrap - }) + }) if item.bom_no: get_exploded_items(item.bom_no, data, indent=indent+1, qty=item.qty) @@ -68,6 +71,12 @@ def get_columns(): "fieldname": "uom", "width": 100 }, + { + "label": "BOM Level", + "fieldtype": "Data", + "fieldname": "bom_level", + "width": 100 + }, { "label": "Standard Description", "fieldtype": "data", diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py index 1c6758e6f36..ed8b93929a1 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -70,12 +70,12 @@ def get_bom_stock(filters): ON bom_item.item_code = ledger.item_code {conditions} WHERE - bom_item.parent = '{bom}' and bom_item.parenttype='BOM' + bom_item.parent = {bom} and bom_item.parenttype='BOM' GROUP BY bom_item.item_code""".format( qty_field=qty_field, table=table, conditions=conditions, - bom=bom, + bom=frappe.db.escape(bom), qty_to_produce=qty_to_produce or 1) ) diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/__init__.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js new file mode 100644 index 00000000000..97e7e0a7d20 --- /dev/null +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js @@ -0,0 +1,105 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Cost of Poor Quality Report"] = { + "filters": [ + { + label: __("Company"), + fieldname: "company", + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + label: __("From Date"), + fieldname:"from_date", + fieldtype: "Datetime", + default: frappe.datetime.convert_to_system_tz(frappe.datetime.add_months(frappe.datetime.now_datetime(), -1)), + reqd: 1 + }, + { + label: __("To Date"), + fieldname:"to_date", + fieldtype: "Datetime", + default: frappe.datetime.now_datetime(), + reqd: 1, + }, + { + label: __("Job Card"), + fieldname: "name", + fieldtype: "Link", + options: "Job Card", + get_query: function() { + return { + filters: { + is_corrective_job_card: 1, + docstatus: 1 + } + } + } + }, + { + label: __("Work Order"), + fieldname: "work_order", + fieldtype: "Link", + options: "Work Order" + }, + { + label: __("Operation"), + fieldname: "operation", + fieldtype: "Link", + options: "Operation", + get_query: function() { + return { + filters: { + is_corrective_operation: 1 + } + } + } + }, + { + label: __("Workstation"), + fieldname: "workstation", + fieldtype: "Link", + options: "Workstation" + }, + { + label: __("Item"), + fieldname: "production_item", + fieldtype: "Link", + options: "Item" + }, + { + label: __("Serial No"), + fieldname: "serial_no", + fieldtype: "Link", + options: "Serial No", + depends_on: "eval: doc.production_item", + get_query: function() { + var item_code = frappe.query_report.get_filter_value('production_item'); + return { + filters: { + item_code: item_code + } + } + } + }, + { + label: __("Batch No"), + fieldname: "batch_no", + fieldtype: "Link", + options: "Batch No", + depends_on: "eval: doc.production_item", + get_query: function() { + var item_code = frappe.query_report.get_filter_value('production_item'); + return { + filters: { + item: item_code + } + } + } + }, + ] +}; diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.json b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.json new file mode 100644 index 00000000000..ee63bc1c287 --- /dev/null +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.json @@ -0,0 +1,33 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-01-11 11:10:58.292896", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "modified": "2021-01-11 11:11:03.594242", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Cost of Poor Quality Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Job Card", + "report_name": "Cost of Poor Quality Report", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Manufacturing User" + }, + { + "role": "Manufacturing Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py new file mode 100644 index 00000000000..9f81e7d26a1 --- /dev/null +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py @@ -0,0 +1,127 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import flt + +def execute(filters=None): + columns, data = [], [] + + columns = get_columns(filters) + data = get_data(filters) + + return columns, data + +def get_data(report_filters): + data = [] + operations = frappe.get_all("Operation", filters = {"is_corrective_operation": 1}) + if operations: + operations = [d.name for d in operations] + fields = ["production_item as item_code", "item_name", "work_order", "operation", + "workstation", "total_time_in_mins", "name", "hour_rate", "serial_no", "batch_no"] + + filters = get_filters(report_filters, operations) + + job_cards = frappe.get_all("Job Card", fields = fields, + filters = filters) + + for row in job_cards: + row.operating_cost = flt(row.hour_rate) * (flt(row.total_time_in_mins) / 60.0) + update_raw_material_cost(row, report_filters) + data.append(row) + + return data + +def get_filters(report_filters, operations): + filters = {"docstatus": 1, "operation": ("in", operations), "is_corrective_job_card": 1} + for field in ["name", "work_order", "operation", "workstation", "company", "serial_no", "batch_no", "production_item"]: + if report_filters.get(field): + if field != 'serial_no': + filters[field] = report_filters.get(field) + else: + filters[field] = ('like', '% {} %'.format(report_filters.get(field))) + + return filters + +def update_raw_material_cost(row, filters): + row.rm_cost = 0.0 + for data in frappe.get_all("Job Card Item", fields = ["amount"], + filters={"parent": row.name, "docstatus": 1}): + row.rm_cost += data.amount + +def get_columns(filters): + return [ + { + "label": _("Job Card"), + "fieldtype": "Link", + "fieldname": "name", + "options": "Job Card", + "width": "100" + }, + { + "label": _("Work Order"), + "fieldtype": "Link", + "fieldname": "work_order", + "options": "Work Order", + "width": "100" + }, + { + "label": _("Item Code"), + "fieldtype": "Link", + "fieldname": "item_code", + "options": "Item", + "width": "100" + }, + { + "label": _("Item Name"), + "fieldtype": "Data", + "fieldname": "item_name", + "width": "100" + }, + { + "label": _("Operation"), + "fieldtype": "Link", + "fieldname": "operation", + "options": "Operation", + "width": "100" + }, + { + "label": _("Serial No"), + "fieldtype": "Data", + "fieldname": "serial_no", + "width": "100" + }, + { + "label": _("Batch No"), + "fieldtype": "Data", + "fieldname": "batch_no", + "width": "100" + }, + { + "label": _("Workstation"), + "fieldtype": "Link", + "fieldname": "workstation", + "options": "Workstation", + "width": "100" + }, + { + "label": _("Operating Cost"), + "fieldtype": "Currency", + "fieldname": "operating_cost", + "width": "100" + }, + { + "label": _("Raw Material Cost"), + "fieldtype": "Currency", + "fieldname": "rm_cost", + "width": "100" + }, + { + "label": _("Total Time (in Mins)"), + "fieldtype": "Float", + "fieldname": "total_time_in_mins", + "width": "100" + } + ] \ No newline at end of file diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.js b/erpnext/manufacturing/report/job_card_summary/job_card_summary.js index bd68db190e7..cb771e49941 100644 --- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.js +++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.js @@ -68,6 +68,18 @@ frappe.query_reports["Job Card Summary"] = { get_data: function(txt) { return frappe.db.get_link_options('Item', txt); } + }, + { + label: __("Workstation"), + fieldname: "workstation", + fieldtype: "Link", + options: "Workstation" + }, + { + label: __("Operation"), + fieldname: "operation", + fieldtype: "Link", + options: "Operation" } ] }; diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.json b/erpnext/manufacturing/report/job_card_summary/job_card_summary.json index 9f08fc34cb8..ecf2b74bbed 100644 --- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.json +++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.json @@ -1,14 +1,16 @@ { - "add_total_row": 0, + "add_total_row": 1, + "columns": [], "creation": "2020-04-20 12:00:21.436619", "disable_prepared_report": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "filters": [], "idx": 0, "is_standard": "Yes", - "letter_head": "Gadgets International", - "modified": "2020-04-20 12:00:21.436619", + "letter_head": "", + "modified": "2020-12-30 11:49:21.713561", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Summary", diff --git a/erpnext/manufacturing/report/production_plan_summary/__init__.py b/erpnext/manufacturing/report/production_plan_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js new file mode 100644 index 00000000000..59396fef16e --- /dev/null +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js @@ -0,0 +1,32 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Production Plan Summary"] = { + "filters": [ + { + fieldname: "production_plan", + label: __("Production Plan"), + fieldtype: "Link", + options: "Production Plan", + reqd: 1, + get_query: function() { + return { + filters: { + "docstatus": 1 + } + }; + } + } + ], + "formatter": function(value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + + if (column.fieldname == "document_name") { + var color = data.pending_qty > 0 ? 'red': 'green'; + value = `${data['document_name']}`; + } + + return value; + }, +}; diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.json b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.json new file mode 100644 index 00000000000..33aca21a6ea --- /dev/null +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.json @@ -0,0 +1,26 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2020-12-27 11:43:39.781793", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2020-12-27 11:43:42.677584", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Plan Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Production Plan", + "report_name": "Production Plan Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Manufacturing User" + } + ] +} \ No newline at end of file diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py new file mode 100644 index 00000000000..81b1791ae81 --- /dev/null +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py @@ -0,0 +1,136 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.utils import flt + +def execute(filters=None): + columns, data = [], [] + data = get_data(filters) + columns = get_column(filters) + + return columns, data + +def get_data(filters): + data = [] + + order_details = {} + get_work_order_details(filters, order_details) + get_purchase_order_details(filters, order_details) + get_production_plan_item_details(filters, data, order_details) + + return data + +def get_production_plan_item_details(filters, data, order_details): + itemwise_indent = {} + + production_plan_doc = frappe.get_cached_doc("Production Plan", filters.get("production_plan")) + for row in production_plan_doc.po_items: + work_order = frappe.get_cached_value("Work Order", {"production_plan_item": row.name, + "bom_no": row.bom_no, "production_item": row.item_code}, "name") + + if row.item_code not in itemwise_indent: + itemwise_indent.setdefault(row.item_code, {}) + + data.append({ + "indent": 0, + "item_code": row.item_code, + "item_name": frappe.get_cached_value("Item", row.item_code, "item_name"), + "qty": row.planned_qty, + "document_type": "Work Order", + "document_name": work_order, + "bom_level": frappe.get_cached_value("BOM", row.bom_no, "bom_level"), + "produced_qty": order_details.get((work_order, row.item_code)).get("produced_qty"), + "pending_qty": flt(row.planned_qty) - flt(order_details.get((work_order, row.item_code)).get("produced_qty")) + }) + + get_production_plan_sub_assembly_item_details(filters, row, production_plan_doc, data, order_details) + +def get_production_plan_sub_assembly_item_details(filters, row, production_plan_doc, data, order_details): + for item in production_plan_doc.sub_assembly_items: + if row.name == item.production_plan_item: + subcontracted_item = (item.type_of_manufacturing == 'Subcontract') + + if subcontracted_item: + docname = frappe.get_cached_value("Purchase Order Item", + {"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)}, "parent") + else: + docname = frappe.get_cached_value("Work Order", + {"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)}, "name") + + data.append({ + "indent": 1, + "item_code": item.production_item, + "item_name": item.item_name, + "qty": item.qty, + "document_type": "Work Order" if not subcontracted_item else "Purchase Order", + "document_name": docname, + "bom_level": item.bom_level, + "produced_qty": order_details.get((docname, item.production_item)).get("produced_qty"), + "pending_qty": flt(item.qty) - flt(order_details.get((docname, item.production_item)).get("produced_qty")) + }) + +def get_work_order_details(filters, order_details): + for row in frappe.get_all("Work Order", filters = {"production_plan": filters.get("production_plan")}, + fields=["name", "produced_qty", "production_plan", "production_item"]): + order_details.setdefault((row.name, row.production_item), row) + +def get_purchase_order_details(filters, order_details): + for row in frappe.get_all("Purchase Order Item", filters = {"production_plan": filters.get("production_plan")}, + fields=["parent", "received_qty as produced_qty", "item_code"]): + order_details.setdefault((row.parent, row.item_code), row) + +def get_column(filters): + return [ + { + "label": "Finished Good", + "fieldtype": "Link", + "fieldname": "item_code", + "width": 300, + "options": "Item" + }, + { + "label": "Item Name", + "fieldtype": "data", + "fieldname": "item_name", + "width": 100 + }, + { + "label": "Document Type", + "fieldtype": "Link", + "fieldname": "document_type", + "width": 150, + "options": "DocType" + }, + { + "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 + } + ] diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py index fb047b230ce..612dad0bf51 100644 --- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py +++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py @@ -19,7 +19,7 @@ def execute(filters=None): return columns, data, None, chart_data def get_data(filters): - query_filters = {"docstatus": 1} + query_filters = {"docstatus": ("<", 2)} fields = ["name", "status", "sales_order", "production_item", "qty", "produced_qty", "planned_start_date", "planned_end_date", "actual_start_date", "actual_end_date", "lead_time"] @@ -62,7 +62,8 @@ def get_chart_based_on_status(data): "Not Started": 0, "In Process": 0, "Stopped": 0, - "Completed": 0 + "Completed": 0, + "Draft": 0 } for d in data: diff --git a/erpnext/non_profit/doctype/member/member.json b/erpnext/non_profit/doctype/member/member.json index f190cfae755..7c1baf1a8d1 100644 --- a/erpnext/non_profit/doctype/member/member.json +++ b/erpnext/non_profit/doctype/member/member.json @@ -26,7 +26,7 @@ "razorpay_details_section", "subscription_id", "customer_id", - "subscription_activated", + "subscription_status", "column_break_21", "subscription_start", "subscription_end" @@ -151,12 +151,6 @@ "fieldname": "column_break_21", "fieldtype": "Column Break" }, - { - "default": "0", - "fieldname": "subscription_activated", - "fieldtype": "Check", - "label": "Subscription Activated" - }, { "fieldname": "subscription_start", "fieldtype": "Date", @@ -166,11 +160,17 @@ "fieldname": "subscription_end", "fieldtype": "Date", "label": "Subscription End" + }, + { + "fieldname": "subscription_status", + "fieldtype": "Select", + "label": "Subscription Status", + "options": "\nActive\nHalted" } ], "image_field": "image", "links": [], - "modified": "2020-11-09 12:12:10.174647", + "modified": "2021-07-11 14:27:26.368039", "modified_by": "Administrator", "module": "Non Profit", "name": "Member", diff --git a/erpnext/non_profit/doctype/member/member.py b/erpnext/non_profit/doctype/member/member.py index efc072ee971..67828d6efc8 100644 --- a/erpnext/non_profit/doctype/member/member.py +++ b/erpnext/non_profit/doctype/member/member.py @@ -28,7 +28,7 @@ class Member(Document): def setup_subscription(self): non_profit_settings = frappe.get_doc('Non Profit Settings') if not non_profit_settings.enable_razorpay_for_memberships: - frappe.throw('Please check Enable Razorpay for Memberships in {0} to setup subscription').format( + frappe.throw(_('Please check Enable Razorpay for Memberships in {0} to setup subscription')).format( get_link_to_form('Non Profit Settings', 'Non Profit Settings')) controller = get_payment_gateway_controller("Razorpay") @@ -84,7 +84,9 @@ def create_member(user_details): "email_id": user_details.email, "pan_number": user_details.pan or None, "membership_type": user_details.plan_id, - "subscription_id": user_details.subscription_id or None + "customer_id": user_details.customer_id or None, + "subscription_id": user_details.subscription_id or None, + "subscription_status": user_details.subscription_status or "" }) member.insert(ignore_permissions=True) diff --git a/erpnext/non_profit/doctype/membership/membership.py b/erpnext/non_profit/doctype/membership/membership.py index e8ae6187b7e..b584116df3c 100644 --- a/erpnext/non_profit/doctype/membership/membership.py +++ b/erpnext/non_profit/doctype/membership/membership.py @@ -196,11 +196,14 @@ def make_invoice(membership, member, plan, settings): return invoice -def get_member_based_on_subscription(subscription_id, email): - members = frappe.get_all("Member", filters={ - "subscription_id": subscription_id, - "email_id": email - }, order_by="creation desc") +def get_member_based_on_subscription(subscription_id, email=None, customer_id=None): + filters = {"subscription_id": subscription_id} + if email: + filters.update({"email_id": email}) + if customer_id: + filters.update({"customer_id": customer_id}) + + members = frappe.get_all("Member", filters=filters, order_by="creation desc") try: return frappe.get_doc("Member", members[0]["name"]) @@ -209,8 +212,6 @@ def get_member_based_on_subscription(subscription_id, email): def verify_signature(data, endpoint="Membership"): - if frappe.flags.in_test or os.environ.get("CI"): - return True signature = frappe.request.headers.get("X-Razorpay-Signature") settings = frappe.get_doc("Non Profit Settings") @@ -225,16 +226,7 @@ def verify_signature(data, endpoint="Membership"): @frappe.whitelist(allow_guest=True) def trigger_razorpay_subscription(*args, **kwargs): data = frappe.request.get_data(as_text=True) - try: - verify_signature(data) - except Exception as e: - log = frappe.log_error(e, "Membership Webhook Verification Error") - notify_failure(log) - return { "status": "Failed", "reason": e} - - if isinstance(data, six.string_types): - data = json.loads(data) - data = frappe._dict(data) + data = process_request_data(data) subscription = data.payload.get("subscription", {}).get("entity", {}) subscription = frappe._dict(subscription) @@ -281,7 +273,7 @@ def trigger_razorpay_subscription(*args, **kwargs): # Update membership values member.subscription_start = datetime.fromtimestamp(subscription.start_at) member.subscription_end = datetime.fromtimestamp(subscription.end_at) - member.subscription_activated = 1 + member.subscription_status = "Active" member.flags.ignore_mandatory = True member.save() @@ -294,9 +286,67 @@ def trigger_razorpay_subscription(*args, **kwargs): message = "{0}\n\n{1}\n\n{2}: {3}".format(e, frappe.get_traceback(), _("Payment ID"), payment.id) log = frappe.log_error(message, _("Error creating membership entry for {0}").format(member.name)) notify_failure(log) - return { "status": "Failed", "reason": e} + return {"status": "Failed", "reason": e} - return { "status": "Success" } + return {"status": "Success"} + + +@frappe.whitelist(allow_guest=True) +def update_halted_razorpay_subscription(*args, **kwargs): + """ + When all retries have been exhausted, Razorpay moves the subscription to the halted state. + The customer has to manually retry the charge or change the card linked to the subscription, + for the subscription to move back to the active state. + """ + if frappe.request: + data = frappe.request.get_data(as_text=True) + data = process_request_data(data) + elif frappe.flags.in_test: + data = kwargs.get("data") + data = frappe._dict(data) + else: + return + + if not data.event == "subscription.halted": + return + + subscription = data.payload.get("subscription", {}).get("entity", {}) + subscription = frappe._dict(subscription) + + try: + member = get_member_based_on_subscription(subscription.id, customer_id=subscription.customer_id) + if not member: + frappe.throw(_("Member with Razorpay Subscription ID {0} not found").format(subscription.id)) + + member.subscription_status = "Halted" + member.flags.ignore_mandatory = True + member.save() + + if subscription.get("notes"): + member = get_additional_notes(member, subscription) + + except Exception as e: + message = "{0}\n\n{1}".format(e, frappe.get_traceback()) + log = frappe.log_error(message, _("Error updating halted status for member {0}").format(member.name)) + notify_failure(log) + return {"status": "Failed", "reason": e} + + return {"status": "Success"} + + +def process_request_data(data): + try: + verify_signature(data) + except Exception as e: + log = frappe.log_error(e, "Membership Webhook Verification Error") + notify_failure(log) + return {"status": "Failed", "reason": e} + + if isinstance(data, six.string_types): + data = json.loads(data) + data = frappe._dict(data) + + return data def get_company_for_memberships(): @@ -362,4 +412,4 @@ def set_expired_status(): `tabMembership` SET `status` = 'Expired' WHERE `status` not in ('Cancelled') AND `to_date` < %s - """, (nowdate())) \ No newline at end of file + """, (nowdate())) diff --git a/erpnext/non_profit/doctype/membership/test_membership.py b/erpnext/non_profit/doctype/membership/test_membership.py index 31da792e534..0f5a9bed826 100644 --- a/erpnext/non_profit/doctype/membership/test_membership.py +++ b/erpnext/non_profit/doctype/membership/test_membership.py @@ -6,6 +6,7 @@ import unittest import frappe import erpnext from erpnext.non_profit.doctype.member.member import create_member +from erpnext.non_profit.doctype.membership.membership import update_halted_razorpay_subscription from frappe.utils import nowdate, add_months class TestMembership(unittest.TestCase): @@ -13,11 +14,16 @@ class TestMembership(unittest.TestCase): plan = setup_membership() # make test member - self.member_doc = create_member(frappe._dict({ - 'fullname': "_Test_Member", - 'email': "_test_member_erpnext@example.com", - 'plan_id': plan.name - })) + self.member_doc = create_member( + frappe._dict({ + "fullname": "_Test_Member", + "email": "_test_member_erpnext@example.com", + "plan_id": plan.name, + "subscription_id": "sub_DEX6xcJ1HSW4CR", + "customer_id": "cust_C0WlbKhp3aLA7W", + "subscription_status": "Active" + }) + ) self.member_doc.make_customer_and_link() self.member = self.member_doc.name @@ -51,6 +57,20 @@ class TestMembership(unittest.TestCase): "to_date": add_months(nowdate(), 3), }) + def test_halted_memberships(self): + make_membership(self.member, { + "from_date": add_months(nowdate(), 2), + "to_date": add_months(nowdate(), 3) + }) + + self.assertEqual(frappe.db.get_value("Member", self.member, "subscription_status"), "Active") + payload = get_subscription_payload() + update_halted_razorpay_subscription(data=payload) + self.assertEqual(frappe.db.get_value("Member", self.member, "subscription_status"), "Halted") + + def tearDown(self): + frappe.db.rollback() + def set_config(key, value): frappe.db.set_value("Non Profit Settings", None, key, value) @@ -115,4 +135,28 @@ def setup_membership(): else: plan = frappe.get_doc("Membership Type", "_rzpy_test_milythm") - return plan \ No newline at end of file + return plan + +def get_subscription_payload(): + return { + "entity": "event", + "account_id": "acc_BFQ7uQEaa7j2z7", + "event": "subscription.halted", + "contains": [ + "subscription" + ], + "payload": { + "subscription": { + "entity": { + "id": "sub_DEX6xcJ1HSW4CR", + "entity": "subscription", + "plan_id": "_rzpy_test_milythm", + "customer_id": "cust_C0WlbKhp3aLA7W", + "status": "halted", + "notes": { + "Important": "Notes for Internal Reference" + }, + } + } + } + } \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c42fcf605e9..a029627ab12 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -284,6 +284,16 @@ 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 +execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True) erpnext.patches.v13_0.update_timesheet_changes erpnext.patches.v13_0.add_doctype_to_sla #14-06-2021 erpnext.patches.v13_0.set_training_event_attendance +erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice +erpnext.patches.v13_0.rename_issue_status_hold_to_on_hold +erpnext.patches.v13_0.update_response_by_variance +erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice +erpnext.patches.v13_0.update_job_card_details +erpnext.patches.v13_0.update_level_in_bom #1234sswef +erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry +erpnext.patches.v13_0.update_subscription_status_in_memberships +erpnext.patches.v13_0.update_amt_in_work_order_required_items diff --git a/erpnext/patches/v12_0/purchase_receipt_status.py b/erpnext/patches/v12_0/purchase_receipt_status.py index 1a99b3163b6..459221e7695 100644 --- a/erpnext/patches/v12_0/purchase_receipt_status.py +++ b/erpnext/patches/v12_0/purchase_receipt_status.py @@ -19,6 +19,9 @@ def execute(): logger.info("purchase_receipt_status: begin patch, PR count: {}" .format(len(affected_purchase_receipts))) + frappe.reload_doc("stock", "doctype", "Purchase Receipt") + frappe.reload_doc("stock", "doctype", "Purchase Receipt Item") + for pr in affected_purchase_receipts: pr_name = pr[0] diff --git a/erpnext/patches/v13_0/add_missing_fg_item_for_stock_entry.py b/erpnext/patches/v13_0/add_missing_fg_item_for_stock_entry.py new file mode 100644 index 00000000000..d7ad1fc6962 --- /dev/null +++ b/erpnext/patches/v13_0/add_missing_fg_item_for_stock_entry.py @@ -0,0 +1,111 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import cstr, flt, cint +from erpnext.stock.stock_ledger import make_sl_entries +from erpnext.controllers.stock_controller import create_repost_item_valuation_entry + +def execute(): + if not frappe.db.has_column('Work Order', 'has_batch_no'): + return + + frappe.reload_doc('manufacturing', 'doctype', 'manufacturing_settings') + if cint(frappe.db.get_single_value('Manufacturing Settings', 'make_serial_no_batch_from_work_order')): + return + + frappe.reload_doc('manufacturing', 'doctype', 'work_order') + filters = { + 'docstatus': 1, + 'produced_qty': ('>', 0), + 'creation': ('>=', '2021-06-29 00:00:00'), + 'has_batch_no': 1 + } + + fields = ['name', 'production_item'] + + work_orders = [d.name for d in frappe.get_all('Work Order', filters = filters, fields=fields)] + + if not work_orders: + return + + repost_stock_entries = [] + stock_entries = frappe.db.sql_list(''' + SELECT + se.name + FROM + `tabStock Entry` se + WHERE + se.purpose = 'Manufacture' and se.docstatus < 2 and se.work_order in {work_orders} + and not exists( + select name from `tabStock Entry Detail` sed where sed.parent = se.name and sed.is_finished_item = 1 + ) + Order BY + se.posting_date, se.posting_time + '''.format(work_orders=tuple(work_orders))) + + if stock_entries: + print('Length of stock entries', len(stock_entries)) + + for stock_entry in stock_entries: + doc = frappe.get_doc('Stock Entry', stock_entry) + doc.set_work_order_details() + doc.load_items_from_bom() + doc.calculate_rate_and_amount() + set_expense_account(doc) + doc.make_batches('t_warehouse') + + if doc.docstatus == 0: + doc.save() + else: + repost_stock_entry(doc) + repost_stock_entries.append(doc) + + for repost_doc in repost_stock_entries: + repost_future_sle_and_gle(repost_doc) + +def set_expense_account(doc): + for row in doc.items: + if row.is_finished_item and not row.expense_account: + row.expense_account = frappe.get_cached_value('Company', doc.company, 'stock_adjustment_account') + +def repost_stock_entry(doc): + doc.db_update() + for child_row in doc.items: + if child_row.is_finished_item: + child_row.db_update() + + sl_entries = [] + finished_item_row = doc.get_finished_item_row() + get_sle_for_target_warehouse(doc, sl_entries, finished_item_row) + + if sl_entries: + try: + make_sl_entries(sl_entries, True) + except Exception: + print(f'SLE entries not posted for the stock entry {doc.name}') + traceback = frappe.get_traceback() + frappe.log_error(traceback) + +def get_sle_for_target_warehouse(doc, sl_entries, finished_item_row): + for d in doc.get('items'): + if cstr(d.t_warehouse) and finished_item_row and d.name == finished_item_row.name: + sle = doc.get_sl_entries(d, { + "warehouse": cstr(d.t_warehouse), + "actual_qty": flt(d.transfer_qty), + "incoming_rate": flt(d.valuation_rate) + }) + + sle.recalculate_rate = 1 + sl_entries.append(sle) + +def repost_future_sle_and_gle(doc): + args = frappe._dict({ + "posting_date": doc.posting_date, + "posting_time": doc.posting_time, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "company": doc.company + }) + + create_repost_item_valuation_entry(args) diff --git a/erpnext/patches/v13_0/bill_for_rejected_quantity_in_purchase_invoice.py b/erpnext/patches/v13_0/bill_for_rejected_quantity_in_purchase_invoice.py new file mode 100644 index 00000000000..7de9fa1e23e --- /dev/null +++ b/erpnext/patches/v13_0/bill_for_rejected_quantity_in_purchase_invoice.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doctype("Buying Settings") + buying_settings = frappe.get_single("Buying Settings") + buying_settings.bill_for_rejected_quantity_in_purchase_invoice = 0 + buying_settings.save() diff --git a/erpnext/patches/v13_0/rename_issue_doctype_fields.py b/erpnext/patches/v13_0/rename_issue_doctype_fields.py index fa1dfed6435..41c51c36dcb 100644 --- a/erpnext/patches/v13_0/rename_issue_doctype_fields.py +++ b/erpnext/patches/v13_0/rename_issue_doctype_fields.py @@ -37,7 +37,7 @@ def execute(): if frappe.db.exists('DocType', 'Opportunity'): opportunities = frappe.db.get_all('Opportunity', fields=['name', 'mins_to_first_response'], order_by='creation desc') - frappe.reload_doc('crm', 'doctype', 'opportunity') + frappe.reload_doctype('Opportunity', force=True) rename_field('Opportunity', 'mins_to_first_response', 'first_response_time') # change fieldtype to duration diff --git a/erpnext/patches/v13_0/rename_issue_status_hold_to_on_hold.py b/erpnext/patches/v13_0/rename_issue_status_hold_to_on_hold.py new file mode 100644 index 00000000000..48325fc2d43 --- /dev/null +++ b/erpnext/patches/v13_0/rename_issue_status_hold_to_on_hold.py @@ -0,0 +1,20 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + if frappe.db.exists('DocType', 'Issue'): + frappe.reload_doc("support", "doctype", "issue") + rename_status() + +def rename_status(): + frappe.db.sql(""" + UPDATE + `tabIssue` + SET + status = 'On Hold' + WHERE + status = 'Hold' + """) \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_amt_in_work_order_required_items.py b/erpnext/patches/v13_0/update_amt_in_work_order_required_items.py new file mode 100644 index 00000000000..eae5ff60b90 --- /dev/null +++ b/erpnext/patches/v13_0/update_amt_in_work_order_required_items.py @@ -0,0 +1,10 @@ +import frappe + +def execute(): + """ Correct amount in child table of required items table.""" + + frappe.reload_doc("manufacturing", "doctype", "work_order") + frappe.reload_doc("manufacturing", "doctype", "work_order_item") + + frappe.db.sql("""UPDATE `tabWork Order Item` SET amount = rate * required_qty""") + diff --git a/erpnext/patches/v13_0/update_job_card_details.py b/erpnext/patches/v13_0/update_job_card_details.py new file mode 100644 index 00000000000..d4e65c6f2f2 --- /dev/null +++ b/erpnext/patches/v13_0/update_job_card_details.py @@ -0,0 +1,16 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc("manufacturing", "doctype", "job_card") + frappe.reload_doc("manufacturing", "doctype", "job_card_item") + frappe.reload_doc("manufacturing", "doctype", "work_order_operation") + + frappe.db.sql(""" update `tabJob Card` jc, `tabWork Order Operation` wo + SET jc.hour_rate = wo.hour_rate + WHERE + jc.operation_id = wo.name and jc.docstatus < 2 and wo.hour_rate > 0 + """) \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_level_in_bom.py b/erpnext/patches/v13_0/update_level_in_bom.py new file mode 100644 index 00000000000..0d03c42e980 --- /dev/null +++ b/erpnext/patches/v13_0/update_level_in_bom.py @@ -0,0 +1,30 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + for document in ["bom", "bom_item", "bom_explosion_item"]: + frappe.reload_doc('manufacturing', 'doctype', document) + + frappe.db.sql(" update `tabBOM` set bom_level = 0 where docstatus = 1") + + bom_list = frappe.db.sql_list("""select name from `tabBOM` bom + where docstatus=1 and is_active=1 and not exists(select bom_no from `tabBOM Item` + where parent=bom.name and ifnull(bom_no, '')!='')""") + + count = 0 + while(count < len(bom_list)): + for parent_bom in get_parent_boms(bom_list[count]): + bom_doc = frappe.get_cached_doc("BOM", parent_bom) + bom_doc.set_bom_level(update=True) + bom_list.append(parent_bom) + count += 1 + +def get_parent_boms(bom_no): + return frappe.db.sql_list(""" + select distinct bom_item.parent from `tabBOM Item` bom_item + where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM' + and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1) + """, bom_no) diff --git a/erpnext/patches/v13_0/update_response_by_variance.py b/erpnext/patches/v13_0/update_response_by_variance.py new file mode 100644 index 00000000000..ef4d9763837 --- /dev/null +++ b/erpnext/patches/v13_0/update_response_by_variance.py @@ -0,0 +1,31 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + if frappe.db.exists('DocType', 'Issue') and frappe.db.count('Issue'): + invalid_issues = frappe.get_all('Issue', { + 'first_responded_on': ['is', 'set'], + 'response_by_variance': ['<', 0] + }, ["name", "response_by_variance", "timestampdiff(Second, `first_responded_on`, `response_by`) as variance"]) + + # issues which has response_by_variance set as -ve + # but diff between first_responded_on & response_by is +ve i.e SLA isn't failed + invalid_issues = [d for d in invalid_issues if d.get('variance') > 0] + + for issue in invalid_issues: + frappe.db.set_value('Issue', issue.get('name'), 'response_by_variance', issue.get('variance'), update_modified=False) + + invalid_issues = frappe.get_all('Issue', { + 'resolution_date': ['is', 'set'], + 'resolution_by_variance': ['<', 0] + }, ["name", "resolution_by_variance", "timestampdiff(Second, `resolution_date`, `resolution_by`) as variance"]) + + # issues which has resolution_by_variance set as -ve + # but diff between resolution_date & resolution_by is +ve i.e SLA isn't failed + invalid_issues = [d for d in invalid_issues if d.get('variance') > 0] + + for issue in invalid_issues: + frappe.db.set_value('Issue', issue.get('name'), 'resolution_by_variance', issue.get('variance'), update_modified=False) diff --git a/erpnext/patches/v13_0/update_subscription_status_in_memberships.py b/erpnext/patches/v13_0/update_subscription_status_in_memberships.py new file mode 100644 index 00000000000..28e650e9ced --- /dev/null +++ b/erpnext/patches/v13_0/update_subscription_status_in_memberships.py @@ -0,0 +1,9 @@ +import frappe + +def execute(): + if frappe.db.exists('DocType', 'Member'): + frappe.reload_doc('Non Profit', 'doctype', 'Member') + + if frappe.db.has_column('Member', 'subscription_activated'): + frappe.db.sql('UPDATE `tabMember` SET subscription_status = "Active" WHERE subscription_activated = 1') + frappe.db.sql_ddl('ALTER table `tabMember` DROP COLUMN subscription_activated') \ No newline at end of file diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.js b/erpnext/payroll/doctype/additional_salary/additional_salary.js index d1ed91fac70..24ffce537c1 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.js +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.js @@ -12,8 +12,12 @@ frappe.ui.form.on('Additional Salary', { } }; }); + }, - frm.trigger('set_earning_component'); + onload: function(frm) { + if (frm.doc.type) { + frm.trigger('set_component_query'); + } }, employee: function(frm) { @@ -46,14 +50,19 @@ frappe.ui.form.on('Additional Salary', { }, company: function(frm) { - frm.trigger('set_earning_component'); + frm.set_value("type", ""); + frm.trigger('set_component_query'); }, - set_earning_component: function(frm) { + set_component_query: function(frm) { if (!frm.doc.company) return; + let filters = {company: frm.doc.company}; + if (frm.doc.type) { + filters.type = frm.doc.type; + } frm.set_query("salary_component", function() { return { - filters: {type: ["in", ["earning", "deduction"]], company: frm.doc.company} + filters: filters }; }); }, diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.json b/erpnext/payroll/doctype/additional_salary/additional_salary.json index 5e17a5cbb7d..d9efe458dcf 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.json +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.json @@ -7,25 +7,30 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "employee_details_section", "naming_series", "employee", "employee_name", - "salary_component", - "type", - "amount", - "ref_doctype", - "ref_docname", - "amended_from", "column_break_5", "company", "department", + "salary_details_section", + "salary_component", + "type", "currency", + "amount", + "column_break_13", + "is_recurring", + "payroll_date", "from_date", "to_date", - "payroll_date", - "is_recurring", + "properties_and_references_section", + "deduct_full_tax_on_selected_payroll_date", + "ref_doctype", + "ref_docname", + "column_break_22", "overwrite_salary_structure_amount", - "deduct_full_tax_on_selected_payroll_date" + "amended_from" ], "fields": [ { @@ -81,7 +86,7 @@ }, { "depends_on": "eval:(doc.is_recurring==0)", - "description": "Date on which this component is applied", + "description": "The date on which Salary Component with Amount will contribute for Earnings/Deduction in Salary Slip. ", "fieldname": "payroll_date", "fieldtype": "Date", "in_list_view": 1, @@ -159,6 +164,7 @@ "fieldname": "ref_docname", "fieldtype": "Dynamic Link", "label": "Reference Document", + "no_copy": 1, "options": "ref_doctype", "read_only": 1 }, @@ -171,11 +177,34 @@ "print_hide": 1, "read_only": 1, "reqd": 1 + }, + { + "fieldname": "employee_details_section", + "fieldtype": "Section Break", + "label": "Employee Details" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "salary_details_section", + "fieldtype": "Section Break", + "label": "Salary Details" + }, + { + "fieldname": "properties_and_references_section", + "fieldtype": "Section Break", + "label": "Properties and References" } ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 22:33:59.098532", + "modified": "2021-05-26 11:10:00.812698", "modified_by": "Administrator", "module": "Payroll", "name": "Additional Salary", diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js index f2892600d12..496c37b2fad 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js @@ -135,10 +135,26 @@ frappe.ui.form.on('Payroll Entry', { }); frm.set_query('employee', 'employees', () => { - if (!frm.doc.company) { - frappe.msgprint(__("Please set a Company")); - return []; + let error_fields = []; + let mandatory_fields = ['company', 'payroll_frequency', 'start_date', 'end_date']; + + let message = __('Mandatory fields required in {0}', [__(frm.doc.doctype)]); + + mandatory_fields.forEach(field => { + if (!frm.doc[field]) { + error_fields.push(frappe.unscrub(field)); + } + }); + + if (error_fields && error_fields.length) { + message = message + '

    "; + frappe.throw({ + message: message, + indicator: 'red', + title: __('Missing Fields') + }); } + return { query: "erpnext.payroll.doctype.payroll_entry.payroll_entry.employee_query", filters: frm.events.get_employee_filters(frm) @@ -148,25 +164,22 @@ frappe.ui.form.on('Payroll Entry', { get_employee_filters: function (frm) { let filters = {}; - filters['company'] = frm.doc.company; - filters['start_date'] = frm.doc.start_date; - filters['end_date'] = frm.doc.end_date; filters['salary_slip_based_on_timesheet'] = frm.doc.salary_slip_based_on_timesheet; - filters['payroll_frequency'] = frm.doc.payroll_frequency; - filters['payroll_payable_account'] = frm.doc.payroll_payable_account; - filters['currency'] = frm.doc.currency; - if (frm.doc.department) { - filters['department'] = frm.doc.department; - } - if (frm.doc.branch) { - filters['branch'] = frm.doc.branch; - } - if (frm.doc.designation) { - filters['designation'] = frm.doc.designation; - } + let fields = ['company', 'start_date', 'end_date', 'payroll_frequency', 'payroll_payable_account', + 'currency', 'department', 'branch', 'designation']; + + fields.forEach(field => { + if (frm.doc[field]) { + filters[field] = frm.doc[field]; + } + }); + if (frm.doc.employees) { - filters['employees'] = frm.doc.employees.filter(d => d.employee).map(d => d.employee); + let employees = frm.doc.employees.filter(d => d.employee).map(d => d.employee); + if (employees && employees.length) { + filters['employees'] = employees; + } } return filters; }, diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 7a70679db47..13cc423fc2c 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -11,6 +11,7 @@ from frappe import _ from erpnext.accounts.utils import get_fiscal_year from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from frappe.desk.reportview import get_match_cond, get_filters_cond +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions class PayrollEntry(Document): def onload(self): @@ -41,7 +42,7 @@ class PayrollEntry(Document): emp_with_sal_slip.append(employee_details.employee) if len(emp_with_sal_slip): - frappe.throw(_("Salary Slip already exists for {0} ").format(comma_and(emp_with_sal_slip))) + frappe.throw(_("Salary Slip already exists for {0}").format(comma_and(emp_with_sal_slip))) def on_cancel(self): frappe.delete_doc("Salary Slip", frappe.db.sql_list("""select name from `tabSalary Slip` @@ -116,7 +117,6 @@ class PayrollEntry(Document): Creates salary slip for selected employees if already not created """ self.check_permission('write') - self.created = 1 employees = [emp.employee for emp in self.employees] if employees: args = frappe._dict({ @@ -211,7 +211,7 @@ class PayrollEntry(Document): return account_dict def make_accrual_jv_entry(self): - self.check_permission('write') + self.check_permission("write") earnings = self.get_salary_component_total(component_type = "earnings") or {} deductions = self.get_salary_component_total(component_type = "deductions") or {} payroll_payable_account = self.payroll_payable_account @@ -219,12 +219,13 @@ class PayrollEntry(Document): precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") if earnings or deductions: - journal_entry = frappe.new_doc('Journal Entry') - journal_entry.voucher_type = 'Journal Entry' - journal_entry.user_remark = _('Accrual Journal Entry for salaries from {0} to {1}')\ + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Journal Entry" + journal_entry.user_remark = _("Accrual Journal Entry for salaries from {0} to {1}")\ .format(self.start_date, self.end_date) journal_entry.company = self.company journal_entry.posting_date = self.posting_date + accounting_dimensions = get_accounting_dimensions() or [] accounts = [] currencies = [] @@ -236,37 +237,34 @@ class PayrollEntry(Document): for acc_cc, amount in earnings.items(): exchange_rate, amt = self.get_amount_and_exchange_rate_for_journal_entry(acc_cc[0], amount, company_currency, currencies) payable_amount += flt(amount, precision) - accounts.append({ + accounts.append(self.update_accounting_dimensions({ "account": acc_cc[0], "debit_in_account_currency": flt(amt, precision), "exchange_rate": flt(exchange_rate), - "party_type": '', "cost_center": acc_cc[1] or self.cost_center, "project": self.project - }) + }, accounting_dimensions)) # Deductions for acc_cc, amount in deductions.items(): exchange_rate, amt = self.get_amount_and_exchange_rate_for_journal_entry(acc_cc[0], amount, company_currency, currencies) payable_amount -= flt(amount, precision) - accounts.append({ + accounts.append(self.update_accounting_dimensions({ "account": acc_cc[0], "credit_in_account_currency": flt(amt, precision), "exchange_rate": flt(exchange_rate), "cost_center": acc_cc[1] or self.cost_center, - "party_type": '', "project": self.project - }) + }, accounting_dimensions)) # Payable amount exchange_rate, payable_amt = self.get_amount_and_exchange_rate_for_journal_entry(payroll_payable_account, payable_amount, company_currency, currencies) - accounts.append({ + accounts.append(self.update_accounting_dimensions({ "account": payroll_payable_account, "credit_in_account_currency": flt(payable_amt, precision), "exchange_rate": flt(exchange_rate), - "party_type": '', "cost_center": self.cost_center - }) + }, accounting_dimensions)) journal_entry.set("accounts", accounts) if len(currencies) > 1: @@ -286,6 +284,12 @@ class PayrollEntry(Document): return jv_name + def update_accounting_dimensions(self, row, accounting_dimensions): + for dimension in accounting_dimensions: + row.update({dimension: self.get(dimension)}) + + return row + def get_amount_and_exchange_rate_for_journal_entry(self, account, amount, company_currency, currencies): conversion_rate = 1 exchange_rate = self.exchange_rate @@ -454,6 +458,7 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account): where t1.name = t2.employee and t2.docstatus = 1 + and t1.status != 'Inactive' %s order by t2.from_date desc """ % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True) @@ -674,9 +679,13 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): conditions = [] include_employees = [] emp_cond = '' + + if not filters.payroll_frequency: + frappe.throw(_('Select Payroll Frequency.')) + if filters.start_date and filters.end_date: employee_list = get_employee_list(filters) - emp = filters.get('employees') + emp = filters.get('employees') or [] include_employees = [employee.employee for employee in employee_list if employee.employee not in emp] filters.pop('start_date') filters.pop('end_date') diff --git a/erpnext/payroll/doctype/salary_component/salary_component.js b/erpnext/payroll/doctype/salary_component/salary_component.js index dbf75140ac1..e9e6f81862c 100644 --- a/erpnext/payroll/doctype/salary_component/salary_component.js +++ b/erpnext/payroll/doctype/salary_component/salary_component.js @@ -4,11 +4,18 @@ frappe.ui.form.on('Salary Component', { setup: function(frm) { frm.set_query("account", "accounts", function(doc, cdt, cdn) { - var d = locals[cdt][cdn]; + let d = frappe.get_doc(cdt, cdn); + + let root_type = "Liability"; + if (frm.doc.type == "Deduction") { + root_type = "Expense"; + } + return { filters: { "is_group": 0, - "company": d.company + "company": d.company, + "root_type": root_type } }; }); diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index c55bec89be8..f82b0d51bb1 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -1088,6 +1088,7 @@ class SalarySlip(TransactionBase): "applicant": self.employee, "docstatus": 1, "repay_from_salary": 1, + "company": self.company }) def make_loan_repayment_entry(self): diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 9e7db977ab1..d730fcf1fa9 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -481,14 +481,19 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None): if not salary_structure: salary_structure = payroll_frequency + " Salary Structure Test for Salary Slip" - employee = frappe.db.get_value("Employee", {"user_id": user}) - salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee=employee) + employee = frappe.db.get_value("Employee", + { + "user_id": user + }, + ["name", "company", "employee_name"], + as_dict=True) + + salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee=employee.name, company=employee.company) salary_slip_name = frappe.db.get_value("Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})}) if not salary_slip_name: - salary_slip = make_salary_slip(salary_structure_doc.name, employee = employee) - salary_slip.employee_name = frappe.get_value("Employee", - {"name":frappe.db.get_value("Employee", {"user_id": user})}, "employee_name") + salary_slip = make_salary_slip(salary_structure_doc.name, employee = employee.name) + salary_slip.employee_name = employee.employee_name salary_slip.payroll_frequency = payroll_frequency salary_slip.posting_date = nowdate() salary_slip.insert() diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py index dce6b7aa3d4..374dd7ee443 100644 --- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py +++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py @@ -119,26 +119,25 @@ def make_salary_structure(salary_structure, payroll_frequency, employee=None, if test_tax: frappe.db.sql("""delete from `tabSalary Structure` where name=%s""",(salary_structure)) - if not frappe.db.exists('Salary Structure', salary_structure): - details = { - "doctype": "Salary Structure", - "name": salary_structure, - "company": company or erpnext.get_default_company(), - "earnings": make_earning_salary_component(test_tax=test_tax, company_list=["_Test Company"]), - "deductions": make_deduction_salary_component(test_tax=test_tax, company_list=["_Test Company"]), - "payroll_frequency": payroll_frequency, - "payment_account": get_random("Account", filters={'account_currency': currency}), - "currency": currency - } - if other_details and isinstance(other_details, dict): - details.update(other_details) - salary_structure_doc = frappe.get_doc(details) - salary_structure_doc.insert() - if not dont_submit: - salary_structure_doc.submit() + if frappe.db.exists("Salary Structure", salary_structure): + frappe.db.delete("Salary Structure", salary_structure) - else: - salary_structure_doc = frappe.get_doc("Salary Structure", salary_structure) + details = { + "doctype": "Salary Structure", + "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"]), + "deductions": make_deduction_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]), + "payroll_frequency": payroll_frequency, + "payment_account": get_random("Account", filters={"account_currency": currency}), + "currency": currency + } + if other_details and isinstance(other_details, dict): + details.update(other_details) + salary_structure_doc = frappe.get_doc(details) + salary_structure_doc.insert() + if not dont_submit: + salary_structure_doc.submit() filters = {'employee':employee, 'docstatus': 1} if not from_date and payroll_period: diff --git a/erpnext/payroll/report/bank_remittance/bank_remittance.py b/erpnext/payroll/report/bank_remittance/bank_remittance.py index 500543ceb02..05a5366a5c1 100644 --- a/erpnext/payroll/report/bank_remittance/bank_remittance.py +++ b/erpnext/payroll/report/bank_remittance/bank_remittance.py @@ -95,6 +95,7 @@ def execute(filters=None): "amount": salary.net_pay, } data.append(row) + return columns, data def get_bank_accounts(): @@ -116,7 +117,7 @@ def get_payroll_entries(accounts, filters): entries = get_all("Payroll Entry", payroll_filter, ["name", "payment_account"]) payment_accounts = [d.payment_account for d in entries] - set_company_account(payment_accounts, entries) + entries = set_company_account(payment_accounts, entries) return entries def get_salary_slips(payroll_entries): diff --git a/erpnext/portal/doctype/homepage/test_homepage.py b/erpnext/portal/doctype/homepage/test_homepage.py index b717491a821..e646775ab32 100644 --- a/erpnext/portal/doctype/homepage/test_homepage.py +++ b/erpnext/portal/doctype/homepage/test_homepage.py @@ -6,12 +6,12 @@ from __future__ import unicode_literals import frappe import unittest from frappe.utils import set_request -from frappe.website.render import render +from frappe.website.serve import get_response class TestHomepage(unittest.TestCase): def test_homepage_load(self): set_request(method='GET', path='home') - response = render() + response = get_response() self.assertEqual(response.status_code, 200) diff --git a/erpnext/portal/doctype/homepage_section/test_homepage_section.py b/erpnext/portal/doctype/homepage_section/test_homepage_section.py index f0aa554858a..5bb9682bc56 100644 --- a/erpnext/portal/doctype/homepage_section/test_homepage_section.py +++ b/erpnext/portal/doctype/homepage_section/test_homepage_section.py @@ -7,7 +7,7 @@ import frappe import unittest from bs4 import BeautifulSoup from frappe.utils import set_request -from frappe.website.render import render +from frappe.website.serve import get_response class TestHomepageSection(unittest.TestCase): def test_homepage_section_card(self): @@ -26,7 +26,7 @@ class TestHomepageSection(unittest.TestCase): pass set_request(method='GET', path='home') - response = render() + response = get_response() self.assertEqual(response.status_code, 200) @@ -59,7 +59,7 @@ class TestHomepageSection(unittest.TestCase): }).insert() set_request(method='GET', path='home') - response = render() + response = get_response() self.assertEqual(response.status_code, 200) diff --git a/erpnext/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py index 3521e7e8bf0..8aa073402ac 100644 --- a/erpnext/portal/product_configurator/test_product_configurator.py +++ b/erpnext/portal/product_configurator/test_product_configurator.py @@ -2,10 +2,8 @@ from __future__ import unicode_literals from bs4 import BeautifulSoup import frappe, unittest -from frappe.utils import set_request, get_html_for_route -from frappe.website.render import render +from frappe.utils import get_html_for_route from erpnext.portal.product_configurator.utils import get_products_for_website -from erpnext.stock.doctype.item.test_item import make_item_variant test_dependencies = ["Item"] @@ -43,6 +41,30 @@ class TestProductConfigurator(unittest.TestCase): "show_variant_in_website": 1 }).insert() + def create_regular_web_item(self, name, item_group=None): + if not frappe.db.exists('Item', name): + doc = frappe.get_doc({ + "description": name, + "item_code": name, + "item_name": name, + "doctype": "Item", + "is_stock_item": 1, + "item_group": item_group or "_Test Item Group", + "stock_uom": "_Test UOM", + "item_defaults": [{ + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "expense_account": "_Test Account Cost for Goods Sold - _TC", + "buying_cost_center": "_Test Cost Center - _TC", + "selling_cost_center": "_Test Cost Center - _TC", + "income_account": "Sales - _TC" + }], + "show_in_website": 1 + }).insert() + else: + doc = frappe.get_doc("Item", name) + return doc + def test_product_list(self): template_items = frappe.get_all('Item', {'show_in_website': 1}) variant_items = frappe.get_all('Item', {'show_variant_in_website': 1}) @@ -79,3 +101,42 @@ class TestProductConfigurator(unittest.TestCase): 'Test Size': ['2XL'] }) self.assertEqual(len(items), 1) + + def test_products_in_multiple_item_groups(self): + """Check if product is visible on multiple item group pages barring its own.""" + from erpnext.shopping_cart.product_query import ProductQuery + + if not frappe.db.exists("Item Group", {"name": "Tech Items"}): + item_group_doc = frappe.get_doc({ + "doctype": "Item Group", + "item_group_name": "Tech Items", + "parent_item_group": "All Item Groups", + "show_in_website": 1 + }).insert() + else: + item_group_doc = frappe.get_doc("Item Group", "Tech Items") + + doc = self.create_regular_web_item("Portal Item", item_group="Tech Items") + if not frappe.db.exists("Website Item Group", {"parent": "Portal Item"}): + doc.append("website_item_groups", { + "item_group": "_Test Item Group Desktops" + }) + doc.save() + + # check if item is visible in its own Item Group's page + engine = ProductQuery() + items = engine.query({}, {"item_group": "Tech Items"}, None, start=0, item_group="Tech Items") + self.assertEqual(len(items), 1) + self.assertEqual(items[0].item_code, "Portal Item") + + # check if item is visible in configured foreign Item Group's page + engine = ProductQuery() + items = engine.query({}, {"item_group": "_Test Item Group Desktops"}, None, start=0, item_group="_Test Item Group Desktops") + item_codes = [row.item_code for row in items] + + self.assertIn(len(items), [2, 3]) + self.assertIn("Portal Item", item_codes) + + # teardown + doc.delete() + item_group_doc.delete() \ No newline at end of file diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py index d77eb2c3966..d60b1a2b05e 100644 --- a/erpnext/portal/product_configurator/utils.py +++ b/erpnext/portal/product_configurator/utils.py @@ -2,6 +2,7 @@ import frappe from frappe.utils import cint from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager from erpnext.shopping_cart.product_info import get_product_info_for_website +from erpnext.setup.doctype.item_group.item_group import get_child_groups def get_field_filter_data(): product_settings = get_product_settings() @@ -89,6 +90,7 @@ def get_products_for_website(field_filters=None, attribute_filters=None, search= def get_products_html_for_website(field_filters=None, attribute_filters=None): field_filters = frappe.parse_json(field_filters) attribute_filters = frappe.parse_json(attribute_filters) + set_item_group_filters(field_filters) items = get_products_for_website(field_filters, attribute_filters) html = ''.join(get_html_for_items(items)) @@ -98,6 +100,10 @@ def get_products_html_for_website(field_filters=None, attribute_filters=None): return html +def set_item_group_filters(field_filters): + if field_filters is not None and 'item_group' in field_filters: + field_filters['item_group'] = [ig[0] for ig in get_child_groups(field_filters['item_group'])] + def get_item_codes_by_attributes(attribute_filters, template_item_code=None): items = [] diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index 39a6024e2cc..5976e016fa9 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -77,9 +77,6 @@ class Task(NestedSet): if flt(self.progress or 0) > 100: frappe.throw(_("Progress % for a task cannot be more than 100.")) - if flt(self.progress) == 100: - self.status = 'Completed' - if self.status == 'Completed': self.progress = 100 diff --git a/erpnext/projects/report/project_profitability/test_project_profitability.py b/erpnext/projects/report/project_profitability/test_project_profitability.py index ea6bdb54ca3..180926fe258 100644 --- a/erpnext/projects/report/project_profitability/test_project_profitability.py +++ b/erpnext/projects/report/project_profitability/test_project_profitability.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import unittest import frappe -from frappe.utils import getdate, nowdate +from frappe.utils import getdate, nowdate, add_days from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.projects.doctype.timesheet.test_timesheet import make_salary_structure_for_timesheet, make_timesheet from erpnext.projects.doctype.timesheet.timesheet import make_salary_slip, make_sales_invoice @@ -16,17 +16,22 @@ class TestProjectProfitability(unittest.TestCase): make_salary_structure_for_timesheet(emp, company='_Test Company') self.timesheet = make_timesheet(emp, simulate = True, is_billable=1) self.salary_slip = make_salary_slip(self.timesheet.name) + holidays = self.salary_slip.get_holidays_for_employee(nowdate(), nowdate()) + if holidays: + frappe.db.set_value('Payroll Settings', None, 'include_holidays_in_total_working_days', 1) + self.salary_slip.submit() self.sales_invoice = make_sales_invoice(self.timesheet.name, '_Test Item', '_Test Customer') self.sales_invoice.due_date = nowdate() self.sales_invoice.submit() frappe.db.set_value('HR Settings', None, 'standard_working_hours', 8) + frappe.db.set_value('Payroll Settings', None, 'include_holidays_in_total_working_days', 0) def test_project_profitability(self): filters = { 'company': '_Test Company', - 'start_date': getdate(), + 'start_date': add_days(getdate(), -3), 'end_date': getdate() } diff --git a/erpnext/public/js/contact.js b/erpnext/public/js/contact.js new file mode 100644 index 00000000000..41a0e8a9f99 --- /dev/null +++ b/erpnext/public/js/contact.js @@ -0,0 +1,16 @@ + + +frappe.ui.form.on("Contact", { + refresh(frm) { + frm.set_query('link_doctype', "links", function() { + return { + query: "frappe.contacts.address_and_contact.filter_dynamic_link_doctypes", + filters: { + fieldtype: ["in", ["HTML", "Text Editor"]], + fieldname: ["in", ["contact_html", "company_description"]], + } + }; + }); + frm.refresh_field("links"); + } +}); diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index ceeecb28a25..7b997a11530 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -156,31 +156,31 @@ cur_frm.cscript.validate_taxes_and_charges = function(cdt, cdn) { var d = locals[cdt][cdn]; var msg = ""; - if(d.account_head && !d.description) { + if (d.account_head && !d.description) { // set description from account head d.description = d.account_head.split(' - ').slice(0, -1).join(' - '); } - if(!d.charge_type && (d.row_id || d.rate || d.tax_amount)) { + if (!d.charge_type && (d.row_id || d.rate || d.tax_amount)) { msg = __("Please select Charge Type first"); d.row_id = ""; d.rate = d.tax_amount = 0.0; - } else if((d.charge_type == 'Actual' || d.charge_type == 'On Net Total') && d.row_id) { + } else if ((d.charge_type == 'Actual' || d.charge_type == 'On Net Total' || d.charge_type == 'On Paid Amount') && d.row_id) { msg = __("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"); d.row_id = ""; - } else if((d.charge_type == 'On Previous Row Amount' || d.charge_type == 'On Previous Row Total') && d.row_id) { + } else if ((d.charge_type == 'On Previous Row Amount' || d.charge_type == 'On Previous Row Total') && d.row_id) { if (d.idx == 1) { msg = __("Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"); d.charge_type = ''; } else if (!d.row_id) { msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); d.row_id = ""; - } else if(d.row_id && d.row_id >= d.idx) { + } else if (d.row_id && d.row_id >= d.idx) { msg = __("Cannot refer row number greater than or equal to current row number for this Charge type"); d.row_id = ""; } } - if(msg) { + if (msg) { frappe.validated = false; refresh_field("taxes"); frappe.throw(msg); diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 680bf04bfc4..86dadd36d6e 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -122,9 +122,20 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac this.set_from_product_bundle(); } + this.toggle_subcontracting_fields(); super.refresh(); } + toggle_subcontracting_fields() { + if (in_list(['Purchase Receipt', 'Purchase Invoice'], this.frm.doc.doctype)) { + this.frm.fields_dict.supplied_items.grid.update_docfield_property('consumed_qty', + 'read_only', this.frm.doc.__onload && this.frm.doc.__onload.backflush_based_on === 'BOM'); + + this.frm.set_df_property('supplied_items', 'cannot_add_rows', 1); + this.frm.set_df_property('supplied_items', 'cannot_delete_rows', 1); + } + } + supplier() { var me = this; erpnext.utils.get_party_details(this.frm, null, null, function(){ diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 32439b68197..a495a9b0c11 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -12,7 +12,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (in_list(["Sales Order", "Quotation"], item.parenttype) && item.blanket_order_rate) { effective_item_rate = item.blanket_order_rate; } - if(item.margin_type == "Percentage"){ + if (item.margin_type == "Percentage") { item.rate_with_margin = flt(effective_item_rate) + flt(effective_item_rate) * ( flt(item.margin_rate_or_amount) / 100); } else { @@ -22,7 +22,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { item_rate = flt(item.rate_with_margin , precision("rate", item)); - if(item.discount_percentage){ + if (item.discount_percentage) { item.discount_amount = flt(item.rate_with_margin) * flt(item.discount_percentage) / 100; } @@ -65,8 +65,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { this.frm.refresh_fields(); } - calculate_discount_amount(){ + calculate_discount_amount() { if (frappe.meta.get_docfield(this.frm.doc.doctype, "discount_amount")) { + this.calculate_item_values(); + this.calculate_net_total(); this.set_discount_amount(); this.apply_discount_amount(); } @@ -265,6 +267,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { frappe.model.round_floats_in(this.frm.doc, ["total", "base_total", "net_total", "base_net_total"]); } + add_taxes_from_item_tax_template(item_tax_map) { + let me = this; + + if (item_tax_map && cint(frappe.defaults.get_default("add_taxes_from_item_tax_template"))) { + if (typeof (item_tax_map) == "string") { + item_tax_map = JSON.parse(item_tax_map); + } + + $.each(item_tax_map, function(tax, rate) { + let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax); + if (!found) { + let child = frappe.model.add_child(me.frm.doc, "taxes"); + child.charge_type = "On Net Total"; + child.account_head = tax; + child.rate = 0; + } + }); + } + } + calculate_taxes() { var me = this; this.frm.doc.rounding_adjustment = 0; @@ -408,6 +430,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { let tax_detail = tax.item_wise_tax_detail; let key = item.item_code || item.item_name; + if(typeof (tax_detail) == "string") { + tax.item_wise_tax_detail = JSON.parse(tax.item_wise_tax_detail); + tax_detail = tax.item_wise_tax_detail; + } + let item_wise_tax_amount = current_tax_amount * this.frm.doc.conversion_rate; if (tax_detail && tax_detail[key]) item_wise_tax_amount += tax_detail[key][1]; diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 0ffda07cde8..33366db6118 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -6,6 +6,7 @@ frappe.provide('erpnext.accounts.dimensions'); erpnext.TransactionController = class TransactionController extends erpnext.taxes_and_totals { setup() { super.setup(); + let me = this; frappe.flags.hide_serial_batch_dialog = true; frappe.ui.form.on(this.frm.doctype + " Item", "rate", function(frm, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); @@ -43,8 +44,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn); }); - - frappe.ui.form.on(this.frm.cscript.tax_table, "rate", function(frm, cdt, cdn) { cur_frm.cscript.calculate_taxes_and_totals(); }); @@ -121,7 +120,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } }); - var me = this; if(this.frm.fields_dict["items"].grid.get_field('batch_no')) { this.frm.set_query("batch_no", "items", function(doc, cdt, cdn) { return me.set_query_for_batch(doc, cdt, cdn); @@ -261,11 +259,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if(!in_list(["Delivery Note", "Sales Invoice", "Purchase Receipt", "Purchase Invoice"], this.frm.doc.doctype)) { return; } - var me = this; - var inspection_type = in_list(["Purchase Receipt", "Purchase Invoice"], this.frm.doc.doctype) + + const me = this; + if (!this.frm.is_new() && this.frm.doc.docstatus === 0) { + this.frm.add_custom_button(__("Quality Inspection(s)"), () => { + me.make_quality_inspection(); + }, __("Create")); + this.frm.page.set_inner_btn_group_as_primary(__('Create')); + } + + const inspection_type = in_list(["Purchase Receipt", "Purchase Invoice"], this.frm.doc.doctype) ? "Incoming" : "Outgoing"; - var quality_inspection_field = this.frm.get_docfield("items", "quality_inspection"); + let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function(row) { if(me.frm.is_new()) return; return { @@ -280,7 +286,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } this.frm.set_query("quality_inspection", "items", function(doc, cdt, cdn) { - var d = locals[cdt][cdn]; + let d = locals[cdt][cdn]; return { filters: { docstatus: 1, @@ -381,7 +387,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if(this.frm.doc.scan_barcode) { frappe.call({ - method: "erpnext.selling.page.point_of_sale.point_of_sale.search_serial_or_batch_or_barcode_number", + method: "erpnext.selling.page.point_of_sale.point_of_sale.search_for_serial_or_batch_or_barcode_number", args: { search_value: this.frm.doc.scan_barcode } }).then(r => { const data = r && r.message; @@ -556,6 +562,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe name: me.frm.doc.name, project: item.project || me.frm.doc.project, qty: item.qty || 1, + net_rate: item.rate, stock_qty: item.stock_qty, conversion_factor: item.conversion_factor, weight_per_unit: item.weight_per_unit, @@ -736,6 +743,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var me = this; var item = frappe.get_doc(cdt, cdn); + if (item && item.doctype === 'Purchase Receipt Item Supplied') { + return; + } + if (item && item.serial_no) { if (!item.item_code) { this.frm.trigger("item_code", cdt, cdn); @@ -877,9 +888,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } - if (this.frm.doc.posting_date) var date = this.frm.doc.posting_date; - else var date = this.frm.doc.transaction_date; - if (frappe.meta.get_docfield(this.frm.doctype, "shipping_address") && in_list(['Purchase Order', 'Purchase Receipt', 'Purchase Invoice'], this.frm.doctype)) { erpnext.utils.get_shipping_address(this.frm, function(){ @@ -953,15 +961,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe (this.frm.doc.payment_schedule && this.frm.doc.payment_schedule.length)) { var message1 = ""; var message2 = ""; - var final_message = "Please clear the "; + var final_message = __("Please clear the") + " "; if (this.frm.doc.payment_terms_template) { - message1 = "selected Payment Terms Template"; + message1 = __("selected Payment Terms Template"); final_message = final_message + message1; } if ((this.frm.doc.payment_schedule || []).length) { - message2 = "Payment Schedule Table"; + message2 = __("Payment Schedule Table"); if (message1.length !== 0) message2 = " and " + message2; final_message = final_message + message2; } @@ -1799,6 +1807,46 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe ]); } + update_item_tax_map() { + let me = this; + let item_codes = []; + let item_rates = {}; + let item_tax_templates = {}; + + $.each(this.frm.doc.items || [], function(i, item) { + if (item.item_code) { + // Use combination of name and item code in case same item is added multiple times + item_codes.push([item.item_code, item.name]); + item_rates[item.name] = item.net_rate; + item_tax_templates[item.name] = item.item_tax_template; + } + }); + + if (item_codes.length) { + return this.frm.call({ + method: "erpnext.stock.get_item_details.get_item_tax_info", + args: { + company: me.frm.doc.company, + tax_category: cstr(me.frm.doc.tax_category), + item_codes: item_codes, + item_rates: item_rates, + item_tax_templates: item_tax_templates + }, + callback: function(r) { + if (!r.exc) { + $.each(me.frm.doc.items || [], function(i, item) { + if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) { + item.item_tax_template = r.message[item.name].item_tax_template; + item.item_tax_rate = r.message[item.name].item_tax_rate; + me.add_taxes_from_item_tax_template(item.item_tax_rate); + } + }); + } + } + }); + } + } + item_tax_template(doc, cdt, cdn) { var me = this; if(me.frm.updating_party_details) return; @@ -1816,7 +1864,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe callback: function(r) { if(!r.exc) { item.item_tax_rate = r.message; - me.add_taxes_from_item_tax_template(item.item_tax_rate); me.calculate_taxes_and_totals(); } } @@ -1827,43 +1874,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } - update_item_tax_map() { - var me = this; - var item_codes = []; - $.each(this.frm.doc.items || [], function(i, item) { - if(item.item_code) { - item_codes.push(item.item_code); - } - }); - - if(item_codes.length) { - return this.frm.call({ - method: "erpnext.stock.get_item_details.get_item_tax_info", - args: { - company: me.frm.doc.company, - tax_category: cstr(me.frm.doc.tax_category), - item_codes: item_codes - }, - callback: function(r) { - if(!r.exc) { - $.each(me.frm.doc.items || [], function(i, item) { - if(item.item_code && r.message.hasOwnProperty(item.item_code)) { - if (!item.item_tax_template) { - item.item_tax_template = r.message[item.item_code].item_tax_template; - item.item_tax_rate = r.message[item.item_code].item_tax_rate; - } - me.add_taxes_from_item_tax_template(item.item_tax_rate); - } else { - item.item_tax_template = ""; - item.item_tax_rate = "{}"; - } - }); - me.calculate_taxes_and_totals(); - } - } - }); - } - } is_recurring() { // set default values for recurring documents @@ -1949,7 +1959,131 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } - get_method_for_payment(){ + make_quality_inspection() { + let data = []; + const fields = [ + { + label: "Items", + fieldtype: "Table", + fieldname: "items", + cannot_add_rows: true, + in_place_edit: true, + data: data, + get_data: () => { + return data; + }, + fields: [ + { + fieldtype: "Data", + fieldname: "docname", + hidden: true + }, + { + fieldtype: "Read Only", + fieldname: "item_code", + label: __("Item Code"), + in_list_view: true + }, + { + fieldtype: "Read Only", + fieldname: "item_name", + label: __("Item Name"), + in_list_view: true + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Accepted Quantity"), + in_list_view: true, + read_only: true + }, + { + fieldtype: "Float", + fieldname: "sample_size", + label: __("Sample Size"), + reqd: true, + in_list_view: true + }, + { + fieldtype: "Data", + fieldname: "description", + label: __("Description"), + hidden: true + }, + { + fieldtype: "Data", + fieldname: "serial_no", + label: __("Serial No"), + hidden: true + }, + { + fieldtype: "Data", + fieldname: "batch_no", + label: __("Batch No"), + hidden: true + } + ] + } + ]; + + const me = this; + const dialog = new frappe.ui.Dialog({ + title: __("Select Items for Quality Inspection"), + fields: fields, + primary_action: function () { + const data = dialog.get_values(); + frappe.call({ + method: "erpnext.controllers.stock_controller.make_quality_inspections", + args: { + doctype: me.frm.doc.doctype, + docname: me.frm.doc.name, + items: data.items + }, + freeze: true, + callback: function (r) { + if (r.message.length > 0) { + if (r.message.length === 1) { + frappe.set_route("Form", "Quality Inspection", r.message[0]); + } else { + frappe.route_options = { + "reference_type": me.frm.doc.doctype, + "reference_name": me.frm.doc.name + }; + frappe.set_route("List", "Quality Inspection"); + } + } + dialog.hide(); + } + }); + }, + primary_action_label: __("Create") + }); + + this.frm.doc.items.forEach(item => { + if (!item.quality_inspection) { + let dialog_items = dialog.fields_dict.items; + dialog_items.df.data.push({ + "docname": item.name, + "item_code": item.item_code, + "item_name": item.item_name, + "qty": item.qty, + "description": item.description, + "serial_no": item.serial_no, + "batch_no": item.batch_no + }); + dialog_items.grid.refresh(); + } + }); + + data = dialog.fields_dict.items.df.data; + if (!data.length) { + frappe.msgprint(__("All items in this document already have a linked Quality Inspection.")); + } else { + dialog.show(); + } + } + + get_method_for_payment() { var method = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry"; if(cur_frm.doc.__onload && cur_frm.doc.__onload.make_payment_via_journal_entry){ if(in_list(['Sales Invoice', 'Purchase Invoice'], cur_frm.doc.doctype)){ diff --git a/erpnext/public/js/help_links.js b/erpnext/public/js/help_links.js index e78992302f1..d0c935f4887 100644 --- a/erpnext/public/js/help_links.js +++ b/erpnext/public/js/help_links.js @@ -54,7 +54,7 @@ frappe.help.help_links["permission-manager"] = [ frappe.help.help_links["Form/System Settings"] = [ { - label: "Naming Series", + label: "System Settings", url: docsUrl + "user/manual/en/setting-up/settings/system-settings", }, ]; @@ -206,7 +206,7 @@ frappe.help.help_links["Form/PayPal Settings"] = [ label: "PayPal Settings", url: docsUrl + - "user/manual/en/setting-up/integrations/paypal-integration", + "user/manual/en/erpnext_integration/paypal-integration", }, ]; @@ -215,14 +215,14 @@ frappe.help.help_links["Form/Razorpay Settings"] = [ label: "Razorpay Settings", url: docsUrl + - "user/manual/en/setting-up/integrations/razorpay-integration", + "user/manual/en/erpnext_integration/razorpay-integration", }, ]; frappe.help.help_links["Form/Dropbox Settings"] = [ { label: "Dropbox Settings", - url: docsUrl + "user/manual/en/setting-up/integrations/dropbox-backup", + url: docsUrl + "user/manual/en/erpnext_integration/dropbox-backup", }, ]; @@ -230,7 +230,7 @@ frappe.help.help_links["Form/LDAP Settings"] = [ { label: "LDAP Settings", url: - docsUrl + "user/manual/en/setting-up/integrations/ldap-integration", + docsUrl + "user/manual/en/erpnext_integration/ldap-integration", }, ]; @@ -239,7 +239,7 @@ frappe.help.help_links["Form/Stripe Settings"] = [ label: "Stripe Settings", url: docsUrl + - "user/manual/en/setting-up/integrations/stripe-integration", + "user/manual/en/erpnext_integration/stripe-integration", }, ]; @@ -644,14 +644,14 @@ frappe.help.help_links["List/Payment Request"] = [ frappe.help.help_links["List/Asset"] = [ { label: "Managing Fixed Assets", - url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + url: docsUrl + "user/manual/en/accounts/opening-balance/fixed_assets", }, ]; frappe.help.help_links["List/Asset Category"] = [ { label: "Asset Category", - url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + url: docsUrl + "user/manual/en/asset/asset-category", }, ]; @@ -663,7 +663,7 @@ frappe.help.help_links["List/Item"] = [ { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, { label: "Item Price", - url: docsUrl + "user/manual/en/stock/item/item-price", + url: docsUrl + "user/manual/en/stock/item-price", }, { label: "Barcode", @@ -672,25 +672,25 @@ frappe.help.help_links["List/Item"] = [ }, { label: "Item Wise Taxation", - url: docsUrl + "user/manual/en/accounts/item-wise-taxation", + url: docsUrl + "user/manual/en/accounts/item-tax-template", }, { label: "Managing Fixed Assets", - url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + url: docsUrl + "user/manual/en/accounts/opening-balance/fixed_assets", }, { label: "Item Codification", - url: docsUrl + "user/manual/en/stock/item/item-codification", + url: docsUrl + "user/manual/en/stock/articles/item-codification", }, { label: "Item Variants", - url: docsUrl + "user/manual/en/stock/item/item-variants", + url: docsUrl + "user/manual/en/stock/item-variants", }, { label: "Item Valuation", url: docsUrl + - "user/manual/en/stock/item/item-valuation-fifo-and-moving-average", + "user/manual/en/stock/articles/item-valuation-fifo-and-moving-average", }, ]; @@ -698,7 +698,7 @@ frappe.help.help_links["Form/Item"] = [ { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, { label: "Item Price", - url: docsUrl + "user/manual/en/stock/item/item-price", + url: docsUrl + "user/manual/en/stock/item-price", }, { label: "Barcode", @@ -707,19 +707,19 @@ frappe.help.help_links["Form/Item"] = [ }, { label: "Item Wise Taxation", - url: docsUrl + "user/manual/en/accounts/item-wise-taxation", + url: docsUrl + "user/manual/en/accounts/item-tax-template", }, { label: "Managing Fixed Assets", - url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + url: docsUrl + "user/manual/en/accounts/opening-balance/fixed_assets", }, { label: "Item Codification", - url: docsUrl + "user/manual/en/stock/item/item-codification", + url: docsUrl + "user/manual/en/stock/articles/item-codification", }, { label: "Item Variants", - url: docsUrl + "user/manual/en/stock/item/item-variants", + url: docsUrl + "user/manual/en/stock/item-variants", }, { label: "Item Valuation", @@ -991,7 +991,7 @@ frappe.help.help_links["Form/BOM"] = [ label: "Nested BOM Structure", url: docsUrl + - "user/manual/en/manufacturing/articles/nested-bom-structure", + "user/manual/en/manufacturing/articles/managing-multi-level-bom", }, ]; diff --git a/erpnext/public/js/payment/payments.js b/erpnext/public/js/payment/payments.js index 7df976c1be7..4c23669dbbb 100644 --- a/erpnext/public/js/payment/payments.js +++ b/erpnext/public/js/payment/payments.js @@ -13,17 +13,16 @@ erpnext.payments = class payments extends erpnext.stock.StockController { this.$body = this.dialog.body; this.set_payment_primary_action(); this.make_keyboard(); - this.select_text() + this.select_text(); } - select_text(){ - var me = this; - $(this.$body).find('.form-control').click(function(){ + select_text() { + $(this.$body).find('.form-control').click(function() { $(this).select(); - }) + }); } - set_payment_primary_action(){ + set_payment_primary_action() { var me = this; this.dialog.set_primary_action(__("Submit"), function() { @@ -50,8 +49,8 @@ erpnext.payments = class payments extends erpnext.stock.StockController { make_multimode_payment(){ var me = this; - if(this.frm.doc.change_amount > 0){ - me.payment_val = me.doc.outstanding_amount + if (this.frm.doc.change_amount > 0) { + me.payment_val = me.doc.outstanding_amount; } this.payments = frappe.model.add_child(this.frm.doc, 'Multi Mode Payment', "payments"); @@ -62,8 +61,8 @@ erpnext.payments = class payments extends erpnext.stock.StockController { show_payment_details(){ var me = this; var multimode_payments = $(this.$body).find('.multimode-payments').empty(); - if(this.frm.doc.payments.length){ - $.each(this.frm.doc.payments, function(index, data){ + if (this.frm.doc.payments.length) { + $.each(this.frm.doc.payments, function(index, data) { $(frappe.render_template('payment_details', { mode_of_payment: data.mode_of_payment, amount: data.amount, @@ -86,14 +85,14 @@ erpnext.payments = class payments extends erpnext.stock.StockController { set_outstanding_amount(){ this.selected_mode = $(this.$body).find(repl("input[idx='%(idx)s']",{'idx': this.idx})); - this.highlight_selected_row() - this.payment_val = 0.0 - if(this.frm.doc.outstanding_amount > 0 && flt(this.selected_mode.val()) == 0.0){ + this.highlight_selected_row(); + this.payment_val = 0.0; + if (this.frm.doc.outstanding_amount > 0 && flt(this.selected_mode.val()) == 0.0) { //When user first time click on row this.payment_val = flt(this.frm.doc.outstanding_amount / this.frm.doc.conversion_rate, precision("outstanding_amount")) this.selected_mode.val(format_currency(this.payment_val, this.frm.doc.currency)); - this.update_payment_amount() - }else if(flt(this.selected_mode.val()) > 0){ + this.update_payment_amount(); + } else if (flt(this.selected_mode.val()) > 0) { //If user click on existing row which has value this.payment_val = flt(this.selected_mode.val()); } @@ -108,68 +107,67 @@ erpnext.payments = class payments extends erpnext.stock.StockController { this.bind_numeric_keys_event(); } - bind_form_control_event(){ + bind_form_control_event() { var me = this; - $(this.$body).find('.pos-payment-row').click(function(){ + $(this.$body).find('.pos-payment-row').click(function() { me.idx = $(this).attr("idx"); - me.set_outstanding_amount() - }) + me.set_outstanding_amount(); + }); - $(this.$body).find('.form-control').click(function(){ + $(this.$body).find('.form-control').click(function() { me.idx = $(this).attr("idx"); me.set_outstanding_amount(); me.update_paid_amount(true); - }) + }); - $(this.$body).find('.write_off_amount').change(function(){ + $(this.$body).find('.write_off_amount').change(function() { me.write_off_amount(flt($(this).val()), precision("write_off_amount")); - }) + }); - $(this.$body).find('.change_amount').change(function(){ + $(this.$body).find('.change_amount').change(function() { me.change_amount(flt($(this).val()), precision("change_amount")); - }) + }); } - highlight_selected_row(){ - var me = this; - var selected_row = $(this.$body).find(repl(".pos-payment-row[idx='%(idx)s']",{'idx': this.idx})); - $(this.$body).find('.pos-payment-row').removeClass('selected-payment-mode') - selected_row.addClass('selected-payment-mode') + highlight_selected_row() { + var selected_row = $(this.$body).find(repl(".pos-payment-row[idx='%(idx)s']", {'idx': this.idx})); + $(this.$body).find('.pos-payment-row').removeClass('selected-payment-mode'); + selected_row.addClass('selected-payment-mode'); $(this.$body).find('.amount').attr('disabled', true); this.selected_mode.attr('disabled', false); } - bind_numeric_keys_event(){ + bind_numeric_keys_event() { var me = this; $(this.$body).find('.pos-keyboard-key').click(function(){ me.payment_val += $(this).text(); - me.selected_mode.val(format_currency(me.payment_val, me.frm.doc.currency)) - me.idx = me.selected_mode.attr("idx") - me.update_paid_amount() - }) + me.selected_mode.val(format_currency(me.payment_val, me.frm.doc.currency)); + me.idx = me.selected_mode.attr("idx"); + me.update_paid_amount(); + }); - $(this.$body).find('.delete-btn').click(function(){ + $(this.$body).find('.delete-btn').click(function() { me.payment_val = cstr(flt(me.selected_mode.val())).slice(0, -1); me.selected_mode.val(format_currency(me.payment_val, me.frm.doc.currency)); - me.idx = me.selected_mode.attr("idx") + me.idx = me.selected_mode.attr("idx"); me.update_paid_amount(); }) } - bind_amount_change_event(){ + bind_amount_change_event() { var me = this; - this.selected_mode.change(function(){ + this.selected_mode.change(function() { me.payment_val = flt($(this).val()) || 0.0; - me.selected_mode.val(format_currency(me.payment_val, me.frm.doc.currency)) - me.idx = me.selected_mode.attr("idx") - me.update_payment_amount() - }) + me.selected_mode.val(format_currency(me.payment_val, me.frm.doc.currency)); + me.idx = me.selected_mode.attr("idx"); + me.update_payment_amount(); + }); } clear_amount() { var me = this; - $(this.$body).find('.clr').click(function(e){ + $(this.$body).find('.clr').click(function(e) { e.stopPropagation(); me.idx = $(this).attr("idx"); me.selected_mode = $(me.$body).find(repl("input[idx='%(idx)s']",{'idx': me.idx})); @@ -177,50 +175,48 @@ erpnext.payments = class payments extends erpnext.stock.StockController { me.selected_mode.val(0.0); me.highlight_selected_row(); me.update_payment_amount(); - }) + }); } write_off_amount(write_off_amount) { - var me = this; - this.frm.doc.write_off_amount = flt(write_off_amount, precision("write_off_amount")); this.frm.doc.base_write_off_amount = flt(this.frm.doc.write_off_amount * this.frm.doc.conversion_rate, precision("base_write_off_amount")); - this.calculate_outstanding_amount(false) - this.show_amounts() + this.calculate_outstanding_amount(false); + this.show_amounts(); } change_amount(change_amount) { var me = this; this.frm.doc.change_amount = flt(change_amount, precision("change_amount")); - this.calculate_write_off_amount() - this.show_amounts() + this.calculate_write_off_amount(); + this.show_amounts(); } update_paid_amount(update_write_off) { var me = this; - if(in_list(['change_amount', 'write_off_amount'], this.idx)){ + if (in_list(['change_amount', 'write_off_amount'], this.idx)) { var value = me.selected_mode.val(); - if(me.idx == 'change_amount'){ - me.change_amount(value) - } else{ + if (me.idx == 'change_amount') { + me.change_amount(value); + } else { if(flt(value) == 0 && update_write_off && me.frm.doc.outstanding_amount > 0) { value = flt(me.frm.doc.outstanding_amount / me.frm.doc.conversion_rate, precision(me.idx)); } - me.write_off_amount(value) + me.write_off_amount(value); } - }else{ - this.update_payment_amount() + } else { + this.update_payment_amount(); } } update_payment_amount(){ var me = this; - $.each(this.frm.doc.payments, function(index, data){ - if(cint(me.idx) == cint(data.idx)){ - data.amount = flt(me.selected_mode.val(), 2) + $.each(this.frm.doc.payments, function(index, data) { + if (cint(me.idx) == cint(data.idx)) { + data.amount = flt(me.selected_mode.val(), 2); } }) @@ -232,8 +228,8 @@ erpnext.payments = class payments extends erpnext.stock.StockController { var me = this; $(this.$body).find(".write_off_amount").val(format_currency(this.frm.doc.write_off_amount, this.frm.doc.currency)); $(this.$body).find('.paid_amount').text(format_currency(this.frm.doc.paid_amount, this.frm.doc.currency)); - $(this.$body).find('.change_amount').val(format_currency(this.frm.doc.change_amount, this.frm.doc.currency)) - $(this.$body).find('.outstanding_amount').text(format_currency(this.frm.doc.outstanding_amount, frappe.get_doc(":Company", this.frm.doc.company).default_currency)) + $(this.$body).find('.change_amount').val(format_currency(this.frm.doc.change_amount, this.frm.doc.currency)); + $(this.$body).find('.outstanding_amount').text(format_currency(this.frm.doc.outstanding_amount, frappe.get_doc(":Company", this.frm.doc.company).default_currency)); this.update_invoice(); } } diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index ef03b01698c..6f5d67c7462 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -147,7 +147,7 @@ erpnext.setup.slides_settings = [ } // Validate bank name - if(me.values.bank_account){ + if(me.values.bank_account) { frappe.call({ async: false, method: "erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts.validate_bank_account", diff --git a/erpnext/public/js/utils/customer_quick_entry.js b/erpnext/public/js/utils/customer_quick_entry.js index efb8dd9d5ca..d2c5c721cc4 100644 --- a/erpnext/public/js/utils/customer_quick_entry.js +++ b/erpnext/public/js/utils/customer_quick_entry.js @@ -1,8 +1,8 @@ frappe.provide('frappe.ui.form'); frappe.ui.form.CustomerQuickEntryForm = class CustomerQuickEntryForm extends frappe.ui.form.QuickEntryForm { - constructor(doctype, after_insert) { - super(doctype, after_insert); + constructor(doctype, after_insert, init_callback, doc, force) { + super(doctype, after_insert, init_callback, doc, force); this.skip_redirect_on_error = true; } diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 808dd5add05..a79eadc7619 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -274,9 +274,9 @@ erpnext.utils.validate_mandatory = function(frm, label, value, trigger_on) { return true; } -erpnext.utils.get_shipping_address = function(frm, callback){ +erpnext.utils.get_shipping_address = function(frm, callback) { if (frm.doc.company) { - if (!(frm.doc.inter_com_order_reference || frm.doc.internal_invoice_reference || + if ((frm.doc.inter_company_order_reference || frm.doc.internal_invoice_reference || frm.doc.internal_order_reference)) { if (callback) { return callback(); diff --git a/erpnext/public/scss/point-of-sale.scss b/erpnext/public/scss/point-of-sale.scss index 9bdaa8d1eeb..c77b2ce3dfa 100644 --- a/erpnext/public/scss/point-of-sale.scss +++ b/erpnext/public/scss/point-of-sale.scss @@ -806,6 +806,9 @@ display: none; float: right; font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } > .cash-shortcuts { @@ -829,6 +832,11 @@ } } } + + > .loyalty-card { + display: flex; + flex-direction: column; + } } } @@ -1134,4 +1142,4 @@ } } } -} \ No newline at end of file +} diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 9402cf9ea48..5962859be5a 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -467,11 +467,15 @@ body.product-page { .btn-change-address { color: var(--blue-500); - box-shadow: none; - border: 1px solid var(--blue-500); } } +.btn-new-address:hover, .btn-change-address:hover { + box-shadow: none; + color: var(--blue-500) !important; + border: 1px solid var(--blue-500); +} + .modal .address-card { .card-body { padding: var(--padding-sm); diff --git a/erpnext/regional/doctype/gst_settings/gst_settings.js b/erpnext/regional/doctype/gst_settings/gst_settings.js index 808f9bc0789..cd682c5403e 100644 --- a/erpnext/regional/doctype/gst_settings/gst_settings.js +++ b/erpnext/regional/doctype/gst_settings/gst_settings.js @@ -35,6 +35,7 @@ frappe.ui.form.on('GST Settings', { return { filters: { company: row.company, + account_type: "Tax", is_group: 0 } }; diff --git a/erpnext/regional/doctype/gst_settings/gst_settings.py b/erpnext/regional/doctype/gst_settings/gst_settings.py index bc956e9fa88..af3d92e59a7 100644 --- a/erpnext/regional/doctype/gst_settings/gst_settings.py +++ b/erpnext/regional/doctype/gst_settings/gst_settings.py @@ -19,6 +19,21 @@ class GSTSettings(Document): from tabAddress where country = "India" and ifnull(gstin, '')!='' ''') self.set_onload('data', data) + def validate(self): + # Validate duplicate accounts + self.validate_duplicate_accounts() + + def validate_duplicate_accounts(self): + account_list = [] + for account in self.get('gst_accounts'): + for fieldname in ['cgst_account', 'sgst_account', 'igst_account', 'cess_account']: + if account.get(fieldname) in account_list: + frappe.throw(_("Account {0} appears multiple times").format( + frappe.bold(account.get(fieldname)))) + + if account.get(fieldname): + account_list.append(account.get(fieldname)) + @frappe.whitelist() def send_reminder(): frappe.has_permission('GST Settings', throw=True) diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py index 641520437fb..ea39fe1813f 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -214,9 +214,8 @@ class GSTR3BReport(Document): for d in item_details: if d.item_code not in self.invoice_items.get(d.parent, {}): - self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, - sum((i.get('taxable_value', 0) or i.get('base_net_amount', 0)) for i in item_details - if i.item_code == d.item_code and i.parent == d.parent)) + self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) + self.invoice_items[d.parent][d.item_code] += d.get('taxable_value', 0) or d.get('base_net_amount', 0) if d.is_nil_exempt and d.item_code not in self.is_nil_exempt: self.is_nil_exempt.append(d.item_code) @@ -322,6 +321,9 @@ class GSTR3BReport(Document): inter_state_supply_details[(gst_category, place_of_supply)]['txval'] += taxable_value inter_state_supply_details[(gst_category, place_of_supply)]['iamt'] += (taxable_value * rate /100) + if self.invoice_cess.get(inv): + self.report_dict['sup_details']['osup_det']['csamt'] += flt(self.invoice_cess.get(inv), 2) + self.set_inter_state_supply(inter_state_supply_details) def set_supplies_liable_to_reverse_charge(self): diff --git a/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py index 3857ce1cdb8..065f80d610a 100644 --- a/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py @@ -46,14 +46,14 @@ class TestGSTR3BReport(unittest.TestCase): make_sales_invoice() create_purchase_invoices() - if frappe.db.exists("GSTR 3B Report", "GSTR3B-March-2019-_Test Address-Billing"): - report = frappe.get_doc("GSTR 3B Report", "GSTR3B-March-2019-_Test Address-Billing") + if frappe.db.exists("GSTR 3B Report", "GSTR3B-March-2019-_Test Address GST-Billing"): + report = frappe.get_doc("GSTR 3B Report", "GSTR3B-March-2019-_Test Address GST-Billing") report.save() else: report = frappe.get_doc({ "doctype": "GSTR 3B Report", "company": "_Test Company GST", - "company_address": "_Test Address-Billing", + "company_address": "_Test Address GST-Billing", "year": getdate().year, "month": month_number_mapping.get(getdate().month) }).insert() @@ -89,7 +89,7 @@ class TestGSTR3BReport(unittest.TestCase): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -117,7 +117,7 @@ def make_sales_invoice(): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -138,7 +138,7 @@ def make_sales_invoice(): si1.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -159,7 +159,7 @@ def make_sales_invoice(): si2.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -195,7 +195,7 @@ def create_purchase_invoices(): pi.append("taxes", { "charge_type": "On Net Total", - "account_head": "CGST - _GST", + "account_head": "Input Tax CGST - _GST", "cost_center": "Main - _GST", "description": "CGST @ 9.0", "rate": 9 @@ -203,7 +203,7 @@ def create_purchase_invoices(): pi.append("taxes", { "charge_type": "On Net Total", - "account_head": "SGST - _GST", + "account_head": "Input Tax SGST - _GST", "cost_center": "Main - _GST", "description": "SGST @ 9.0", "rate": 9 @@ -410,10 +410,10 @@ def make_company(): company.country = "India" company.insert() - if not frappe.db.exists('Address', '_Test Address-Billing'): + if not frappe.db.exists('Address', '_Test Address GST-Billing'): address = frappe.get_doc({ + "address_title": "_Test Address GST", "address_line1": "_Test Address Line 1", - "address_title": "_Test Address", "address_type": "Billing", "city": "_Test City", "state": "Test State", @@ -444,9 +444,9 @@ def set_account_heads(): if not gst_account: gst_settings.append("gst_accounts", { "company": "_Test Company GST", - "cgst_account": "CGST - _GST", - "sgst_account": "SGST - _GST", - "igst_account": "IGST - _GST", + "cgst_account": "Output Tax CGST - _GST", + "sgst_account": "Output Tax SGST - _GST", + "igst_account": "Output Tax IGST - _GST" }) gst_settings.save() diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index 23d4fe9030b..8ad30fa9106 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -1,6 +1,8 @@ erpnext.setup_einvoice_actions = (doctype) => { frappe.ui.form.on(doctype, { async refresh(frm) { + if (frm.doc.docstatus == 2) return; + const res = await frappe.call({ method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility', args: { doc: frm.doc } @@ -111,7 +113,7 @@ erpnext.setup_einvoice_actions = (doctype) => { if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) { const action = () => { - let message = __('Cancellation of e-way bill is currently not supported. '); + let message = __('Cancellation of e-way bill is currently not supported.') + ' '; message += '

    '; message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.'); diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 843fb012b91..81c7a6b9a07 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -38,11 +38,14 @@ def validate_eligibility(doc): einvoicing_eligible_from = frappe.db.get_single_value('E Invoice Settings', 'applicable_from') or '2021-04-01' if getdate(doc.get('posting_date')) < getdate(einvoicing_eligible_from): return False - + invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') }) invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') - no_taxes_applied = not doc.get('taxes') + + # if export invoice, then taxes can be empty + # invoice can only be ineligible if no taxes applied and is not an export invoice + no_taxes_applied = not doc.get('taxes') and not doc.get('gst_category') == 'Overseas' has_non_gst_item = any(d for d in doc.get('items', []) if d.get('is_non_gst')) if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied or has_non_gst_item: @@ -135,7 +138,7 @@ def validate_address_fields(address, is_shipping_address): def get_party_details(address_name, is_shipping_address=False): addr = frappe.get_doc('Address', address_name) - + validate_address_fields(addr, is_shipping_address) if addr.gst_state_number == 97: @@ -188,18 +191,14 @@ def get_item_list(invoice): item.qty = abs(item.qty) - if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: - item.discount_amount = abs(item.base_amount - item.base_net_amount) - else: - item.discount_amount = 0 - - item.unit_rate = abs((abs(item.taxable_value) - item.discount_amount)/ item.qty) - item.gross_amount = abs(item.taxable_value) + item.discount_amount + item.unit_rate = abs(item.taxable_value / item.qty) + item.gross_amount = abs(item.taxable_value) item.taxable_value = abs(item.taxable_value) + item.discount_amount = 0 item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None - item.is_service_item = 'N' if frappe.db.get_value('Item', d.item_code, 'is_stock_item') else 'Y' + item.is_service_item = 'Y' if item.gst_hsn_code and item.gst_hsn_code[:2] == "99" else 'N' item.serial_no = "" item = update_item_taxes(invoice, item) @@ -254,18 +253,8 @@ def update_item_taxes(invoice, item): def get_invoice_value_details(invoice): invoice_value_details = frappe._dict(dict()) - - if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: - # Discount already applied on net total which means on items - invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) - invoice_value_details.invoice_discount_amt = 0 - elif invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount: - invoice_value_details.invoice_discount_amt = invoice.base_discount_amount - invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) - else: - invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) - # since tax already considers discount amount - invoice_value_details.invoice_discount_amt = 0 + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) + invoice_value_details.invoice_discount_amt = 0 invoice_value_details.round_off = invoice.base_rounding_adjustment invoice_value_details.base_grand_total = abs(invoice.base_rounded_total) or abs(invoice.base_grand_total) @@ -287,8 +276,7 @@ def update_invoice_taxes(invoice, invoice_value_details): considered_rows = [] for t in invoice.taxes: - tax_amount = t.base_tax_amount if (invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount) \ - else t.base_tax_amount_after_discount_amount + tax_amount = t.base_tax_amount_after_discount_amount if t.account_head in gst_accounts_list: if t.account_head in gst_accounts.cess_account: # using after discount amt since item also uses after discount amt for cess calc @@ -401,13 +389,16 @@ def validate_totals(einvoice): if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1: frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.')) - if abs(flt(value_details['TotInvVal']) + flt(value_details['Discount']) - flt(value_details['OthChrg']) - total_item_value) > 1: + if abs( + flt(value_details['TotInvVal']) + flt(value_details['Discount']) - + flt(value_details['OthChrg']) - flt(value_details['RndOffAmt']) - + total_item_value) > 1: frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.')) calculated_invoice_value = \ flt(value_details['AssVal']) + flt(value_details['CgstVal']) \ + flt(value_details['SgstVal']) + flt(value_details['IgstVal']) \ - + flt(value_details['OthChrg']) - flt(value_details['Discount']) + + flt(value_details['OthChrg']) + flt(value_details['RndOffAmt']) - flt(value_details['Discount']) if abs(flt(value_details['TotInvVal']) - calculated_invoice_value) > 1: frappe.throw(_('Total Item Value + Taxes - Discount is not equal to the Invoice Grand Total. Please check taxes / discounts for any correction.')) @@ -995,7 +986,7 @@ class GSPConnector(): self.invoice.failure_description = self.get_failure_message(errors) if errors else "" self.update_invoice() frappe.db.commit() - + def get_failure_message(self, errors): if isinstance(errors, list): errors = ', '.join(errors) @@ -1052,7 +1043,7 @@ def generate_einvoices(docnames): _('{} e-invoices generated successfully').format(success), title=_('Bulk E-Invoice Generation Complete') ) - + else: enqueue_bulk_action(schedule_bulk_generate_irn, docnames=docnames) diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index 3e0b9b733b6..92654608da5 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -12,7 +12,10 @@ from erpnext.accounts.utils import get_fiscal_year, FiscalYearError from frappe.utils import today def setup(company=None, patch=True): - setup_company_independent_fixtures(patch=patch) + # Company independent fixtures should be called only once at the first company setup + if frappe.db.count('Company', {'country': 'India'}) <=1: + setup_company_independent_fixtures(patch=patch) + if not patch: make_fixtures(company) @@ -25,6 +28,7 @@ def setup_company_independent_fixtures(patch=False): frappe.enqueue('erpnext.regional.india.setup.add_hsn_sac_codes', now=frappe.flags.in_test) create_gratuity_rule() add_print_formats() + update_accounts_settings_for_taxes() def add_hsn_sac_codes(): if frappe.flags.in_test and frappe.flags.created_hsn_codes: @@ -121,10 +125,12 @@ def add_print_formats(): def make_property_setters(patch=False): # GST rules do not allow for an invoice no. bigger than 16 characters journal_entry_types = frappe.get_meta("Journal Entry").get_options("voucher_type").split("\n") + ['Reversal Of ITC'] + sales_invoice_series = ['SINV-.YY.-', 'SRET-.YY.-', ''] + frappe.get_meta("Sales Invoice").get_options("naming_series").split("\n") + purchase_invoice_series = ['PINV-.YY.-', 'PRET-.YY.-', ''] + frappe.get_meta("Purchase Invoice").get_options("naming_series").split("\n") if not patch: - make_property_setter('Sales Invoice', 'naming_series', 'options', 'SINV-.YY.-\nSRET-.YY.-', '') - make_property_setter('Purchase Invoice', 'naming_series', 'options', 'PINV-.YY.-\nPRET-.YY.-', '') + make_property_setter('Sales Invoice', 'naming_series', 'options', '\n'.join(sales_invoice_series), '') + make_property_setter('Purchase Invoice', 'naming_series', 'options', '\n'.join(purchase_invoice_series), '') make_property_setter('Journal Entry', 'voucher_type', 'options', '\n'.join(journal_entry_types), '') def make_custom_fields(update=True): @@ -680,7 +686,7 @@ def make_custom_fields(update=True): def make_fixtures(company=None): docs = [] - company = company.name if company else frappe.db.get_value("Global Defaults", None, "default_company") + company = company or frappe.db.get_value("Global Defaults", None, "default_company") set_salary_components(docs) set_tds_account(docs, company) @@ -698,6 +704,53 @@ def make_fixtures(company=None): # create records for Tax Withholding Category set_tax_withholding_category(company) +def update_regional_tax_settings(country, company): + # Will only add default GST accounts if present + input_account_names = ['Input Tax CGST', 'Input Tax SGST', 'Input Tax IGST'] + output_account_names = ['Output Tax CGST', 'Output Tax SGST', 'Output Tax IGST'] + rcm_accounts = ['Input Tax CGST RCM', 'Input Tax SGST RCM', 'Input Tax IGST RCM'] + gst_settings = frappe.get_single('GST Settings') + existing_account_list = [] + + for account in gst_settings.get('gst_accounts'): + for key in ['cgst_account', 'sgst_account', 'igst_account']: + existing_account_list.append(account.get(key)) + + gst_accounts = frappe._dict(frappe.get_all("Account", + {'company': company, 'account_name': ('in', input_account_names + + output_account_names + rcm_accounts)}, ['account_name', 'name'], as_list=1)) + + add_accounts_in_gst_settings(company, input_account_names, gst_accounts, + existing_account_list, gst_settings) + add_accounts_in_gst_settings(company, output_account_names, gst_accounts, + existing_account_list, gst_settings) + add_accounts_in_gst_settings(company, rcm_accounts, gst_accounts, + existing_account_list, gst_settings, is_reverse_charge=1) + + gst_settings.save() + +def add_accounts_in_gst_settings(company, account_names, gst_accounts, + existing_account_list, gst_settings, is_reverse_charge=0): + accounts_not_added = 1 + + for account in account_names: + # Default Account Added does not exists + if not gst_accounts.get(account): + accounts_not_added = 0 + + # Check if already added in GST Settings + if gst_accounts.get(account) in existing_account_list: + accounts_not_added = 0 + + if accounts_not_added: + gst_settings.append('gst_accounts', { + 'company': company, + 'cgst_account': gst_accounts.get(account_names[0]), + 'sgst_account': gst_accounts.get(account_names[1]), + 'igst_account': gst_accounts.get(account_names[2]), + 'is_reverse_charge_account': is_reverse_charge + }) + def set_salary_components(docs): docs.extend([ {'doctype': 'Salary Component', 'salary_component': 'Professional Tax', @@ -731,13 +784,14 @@ def set_tax_withholding_category(company): docs = get_tds_details(accounts, fiscal_year) for d in docs: - try: + if not frappe.db.exists("Tax Withholding Category", d.get("name")): doc = frappe.get_doc(d) + doc.flags.ignore_validate = True doc.flags.ignore_permissions = True doc.flags.ignore_mandatory = True doc.insert() - except frappe.DuplicateEntryError: - doc = frappe.get_doc("Tax Withholding Category", d.get("name")) + else: + doc = frappe.get_doc("Tax Withholding Category", d.get("name"), for_update=True) if accounts: doc.append("accounts", accounts[0]) @@ -749,11 +803,12 @@ def set_tax_withholding_category(company): doc.append("rates", d.get('rates')[0]) doc.flags.ignore_permissions = True + doc.flags.ignore_validate = True doc.flags.ignore_mandatory = True + doc.flags.ignore_links = True doc.save() def set_tds_account(docs, company): - abbr = frappe.get_value("Company", company, "abbr") parent_account = frappe.db.get_value("Account", filters = {"account_name": "Duties and Taxes", "company": company}) if parent_account: docs.extend([ @@ -912,7 +967,6 @@ def get_tds_details(accounts, fiscal_year): ] def create_gratuity_rule(): - # Standard Indain Gratuity Rule if not frappe.db.exists("Gratuity Rule", "Indian Standard Gratuity Rule"): rule = frappe.new_doc("Gratuity Rule") @@ -930,3 +984,7 @@ def create_gratuity_rule(): rule.flags.ignore_mandatory = True rule.save() + +def update_accounts_settings_for_taxes(): + if frappe.db.count('Company') == 1: + frappe.db.set_value('Accounts Settings', None, "add_taxes_from_item_tax_template", 0) \ No newline at end of file diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 075c698fead..fbe47d0532b 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -431,9 +431,11 @@ def get_ewb_data(dt, dn): company_address = frappe.get_doc('Address', doc.company_address) billing_address = frappe.get_doc('Address', doc.customer_address) + #added dispatch address + dispatch_address = frappe.get_doc('Address', doc.dispatch_address_name) if doc.dispatch_address_name else company_address shipping_address = frappe.get_doc('Address', doc.shipping_address_name) - data = get_address_details(data, doc, company_address, billing_address) + data = get_address_details(data, doc, company_address, billing_address, dispatch_address) data.itemList = [] data.totalValue = doc.total @@ -519,10 +521,10 @@ def get_gstins_for_company(company): `tabDynamic Link`.link_name = %(company)s""", {"company": company}) return company_gstins -def get_address_details(data, doc, company_address, billing_address): +def get_address_details(data, doc, company_address, billing_address, dispatch_address): data.fromPincode = validate_pincode(company_address.pincode, 'Company Address') - data.fromStateCode = data.actualFromStateCode = validate_state_code( - company_address.gst_state_number, 'Company Address') + data.fromStateCode = validate_state_code(company_address.gst_state_number, 'Company Address') + data.actualFromStateCode = validate_state_code(dispatch_address.gst_state_number, 'Dispatch Address') if not doc.billing_address_gstin or len(doc.billing_address_gstin) < 15: data.toGstin = 'URP' @@ -817,12 +819,8 @@ def update_taxable_values(doc, method): considered_rows.append(prev_row_id) for item in doc.get('items'): - if doc.apply_discount_on == 'Grand Total' and doc.discount_amount: - proportionate_value = item.base_amount if doc.base_total else item.qty - total_value = doc.base_total if doc.base_total else doc.total_qty - else: - proportionate_value = item.base_net_amount if doc.base_net_total else item.qty - total_value = doc.base_net_total if doc.base_net_total else doc.total_qty + proportionate_value = item.base_net_amount if doc.base_net_total else item.qty + total_value = doc.base_net_total if doc.base_net_total else doc.total_qty applicable_charges = flt(flt(proportionate_value * (flt(additional_taxes) / flt(total_value)), item.precision('taxable_value'))) @@ -838,8 +836,16 @@ def get_depreciation_amount(asset, depreciable_value, row): depreciation_left = flt(row.total_number_of_depreciations) - flt(asset.number_of_depreciations_booked) if row.depreciation_method in ("Straight Line", "Manual"): - depreciation_amount = (flt(row.value_after_depreciation) - - flt(row.expected_value_after_useful_life)) / depreciation_left + # if the Depreciation Schedule is being prepared for the first time + if not asset.flags.increase_in_asset_life: + depreciation_amount = (flt(row.value_after_depreciation) - + flt(row.expected_value_after_useful_life)) / depreciation_left + + # if the Depreciation Schedule is being modified after Asset Repair + else: + depreciation_amount = (flt(row.value_after_depreciation) - + flt(row.expected_value_after_useful_life)) / (date_diff(asset.to_date, asset.available_for_use_date) / 365) + else: rate_of_depreciation = row.rate_of_depreciation # if its the first depreciation diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json index 4deb073a53d..d0000ad50df 100644 --- a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json @@ -11,7 +11,7 @@ "is_standard": "Yes", "json": "{}", "letter_head": "Logo", - "modified": "2021-03-12 12:36:48.689413", + "modified": "2021-03-13 12:36:48.689413", "modified_by": "Administrator", "module": "Regional", "name": "E-Invoice Summary", diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 1e28a40f811..b81fa810fe1 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -147,6 +147,13 @@ class Gstr1Report(object): def get_invoice_data(self): self.invoices = frappe._dict() conditions = self.get_conditions() + + company_gstins = get_company_gstin_number(self.filters.get('company'), all_gstins=True) + + self.filters.update({ + 'company_gstins': company_gstins + }) + invoice_data = frappe.db.sql(""" select {select_columns} @@ -193,6 +200,9 @@ class Gstr1Report(object): elif self.filters.get("type_of_business") == "EXPORT": conditions += """ AND is_return !=1 and gst_category = 'Overseas' """ + + conditions += " AND IFNULL(billing_address_gstin, '') NOT IN %(company_gstins)s" + return conditions def get_invoice_items(self): @@ -207,9 +217,8 @@ class Gstr1Report(object): for d in items: if d.item_code not in self.invoice_items.get(d.parent, {}): - self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, - sum((i.get('taxable_value', 0) or i.get('base_net_amount', 0)) for i in items - if i.item_code == d.item_code and i.parent == d.parent)) + self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) + self.invoice_items[d.parent][d.item_code] += d.get('taxable_value', 0) or d.get('base_net_amount', 0) item_tax_rate = {} @@ -574,7 +583,7 @@ class Gstr1Report(object): def get_json(filters, report_name, data): filters = json.loads(filters) report_data = json.loads(data) - gstin = get_company_gstin_number(filters["company"]) + gstin = get_company_gstin_number(filters.get("company"), filters.get("company_address")) fp = "%02d%s" % (getdate(filters["to_date"]).month, getdate(filters["to_date"]).year) @@ -810,23 +819,30 @@ def get_rate_and_tax_details(row, gstin): return {"num": int(num), "itm_det": itm_det} -def get_company_gstin_number(company): - filters = [ - ["is_your_company_address", "=", 1], - ["Dynamic Link", "link_doctype", "=", "Company"], - ["Dynamic Link", "link_name", "=", company], - ["Dynamic Link", "parenttype", "=", "Address"], - ] +def get_company_gstin_number(company, address=None, all_gstins=False): + gstin = '' + if address: + gstin = frappe.db.get_value("Address", address, "gstin") - gstin = frappe.get_all("Address", filters=filters, fields=["gstin"]) + if not gstin: + filters = [ + ["is_your_company_address", "=", 1], + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", company], + ["Dynamic Link", "parenttype", "=", "Address"], + ] + gstin = frappe.get_all("Address", filters=filters, pluck="gstin") + if gstin and not all_gstins: + gstin = gstin[0] - if gstin: - return gstin[0]["gstin"] - else: - frappe.throw(_("Please set valid GSTIN No. in Company Address for company {0}").format( - frappe.bold(company) + if not gstin: + address = frappe.bold(address) if address else "" + frappe.throw(_("Please set valid GSTIN No. in Company Address {} for company {}").format( + address, frappe.bold(company) )) + return gstin + @frappe.whitelist() def download_json_file(): ''' download json content in a file ''' diff --git a/erpnext/selling/doctype/campaign/README.md b/erpnext/selling/doctype/campaign/README.md deleted file mode 100644 index a8373184027..00000000000 --- a/erpnext/selling/doctype/campaign/README.md +++ /dev/null @@ -1 +0,0 @@ -Sales campaign / promotion, like special discount, exhibition, newsletter etc. \ No newline at end of file diff --git a/erpnext/selling/doctype/campaign/__init__.py b/erpnext/selling/doctype/campaign/__init__.py deleted file mode 100644 index baffc488252..00000000000 --- a/erpnext/selling/doctype/campaign/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/erpnext/selling/doctype/campaign/campaign.js b/erpnext/selling/doctype/campaign/campaign.js deleted file mode 100644 index 72a90d053f2..00000000000 --- a/erpnext/selling/doctype/campaign/campaign.js +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt - -frappe.ui.form.on("Campaign", "refresh", function(frm) { - erpnext.toggle_naming_series(); - if(frm.doc.__islocal) { - frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series"); - } - else{ - cur_frm.add_custom_button(__("View Leads"), function() { - frappe.route_options = {"source": "Campaign","campaign_name": frm.doc.name} - frappe.set_route("List", "Lead"); - }, "fa fa-list", true); - } -}) diff --git a/erpnext/selling/doctype/campaign/campaign_dashboard.py b/erpnext/selling/doctype/campaign/campaign_dashboard.py deleted file mode 100644 index 3cef560c32f..00000000000 --- a/erpnext/selling/doctype/campaign/campaign_dashboard.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return { - 'fieldname': 'campaign_name', - 'transactions': [ - { - 'label': _('Email Campaigns'), - 'items': ['Email Campaign'] - }, - { - 'label': _('Social Media Campaigns'), - 'items': ['Social Media Post'] - } - ] - } diff --git a/erpnext/selling/doctype/campaign/test_campaign.py b/erpnext/selling/doctype/campaign/test_campaign.py deleted file mode 100644 index 4d062ff84c6..00000000000 --- a/erpnext/selling/doctype/campaign/test_campaign.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals - - -import frappe -test_records = frappe.get_test_records('Campaign') \ No newline at end of file diff --git a/erpnext/selling/doctype/campaign/test_records.json b/erpnext/selling/doctype/campaign/test_records.json deleted file mode 100644 index 625d3b377b2..00000000000 --- a/erpnext/selling/doctype/campaign/test_records.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "campaign_name": "_Test Campaign", - "doctype": "Campaign" - }, - { - "campaign_name": "_Test Campaign 1", - "doctype": "Campaign" - } -] \ No newline at end of file diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index 825b170a901..28494662673 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -130,6 +130,10 @@ frappe.ui.form.on("Customer", { erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name); }, __('Create')); + frm.add_custom_button(__('Get Customer Group Details'), function () { + frm.trigger("get_customer_group_details"); + }, __('Actions')); + // indicator erpnext.utils.set_party_dashboard_indicators(frm); @@ -145,4 +149,15 @@ frappe.ui.form.on("Customer", { if(frm.doc.lead_name) frappe.model.clear_doc("Lead", frm.doc.lead_name); }, -}); \ No newline at end of file + get_customer_group_details: function(frm) { + frappe.call({ + method: "get_customer_group_details", + doc: frm.doc, + callback: function() { + frm.refresh(); + } + }); + + } +}); + diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 818888c0c12..3b62081e24c 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -78,6 +78,29 @@ class Customer(TransactionBase): if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100: frappe.throw(_("Total contribution percentage should be equal to 100")) + @frappe.whitelist() + def get_customer_group_details(self): + doc = frappe.get_doc('Customer Group', self.customer_group) + self.accounts = self.credit_limits = [] + self.payment_terms = self.default_price_list = "" + + tables = [["accounts", "account"], ["credit_limits", "credit_limit"]] + fields = ["payment_terms", "default_price_list"] + + for row in tables: + table, field = row[0], row[1] + if not doc.get(table): continue + + for entry in doc.get(table): + child = self.append(table) + child.update({"company": entry.company, field: entry.get(field)}) + + for field in fields: + if not doc.get(field): continue + self.update({field: doc.get(field)}) + + self.save() + def check_customer_group_change(self): frappe.flags.customer_group_changed = False diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 7761aa70fb2..b1a5b52f963 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -27,6 +27,42 @@ class TestCustomer(unittest.TestCase): def tearDown(self): set_credit_limit('_Test Customer', '_Test Company', 0) + def test_get_customer_group_details(self): + doc = frappe.new_doc("Customer Group") + doc.customer_group_name = "_Testing Customer Group" + doc.payment_terms = "_Test Payment Term Template 3" + doc.accounts = [] + doc.default_price_list = "Standard Buying" + doc.credit_limits = [] + test_account_details = { + "company": "_Test Company", + "account": "Creditors - _TC", + } + test_credit_limits = { + "company": "_Test Company", + "credit_limit": 350000 + } + doc.append("accounts", test_account_details) + doc.append("credit_limits", test_credit_limits) + doc.insert() + + c_doc = frappe.new_doc("Customer") + 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.insert() + c_doc.get_customer_group_details() + self.assertEqual(c_doc.payment_terms, "_Test Payment Term Template 3") + + self.assertEqual(c_doc.accounts[0].company, "_Test Company") + self.assertEqual(c_doc.accounts[0].account, "Creditors - _TC") + + self.assertEqual(c_doc.credit_limits[0].company, "_Test Company") + self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000) + c_doc.delete() + doc.delete() + def test_party_details(self): from erpnext.accounts.party import get_party_details diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index d3281f733f0..ae3482f402b 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals import frappe +from frappe.utils import get_link_to_form + from frappe import _ from frappe.model.document import Document @@ -18,6 +20,27 @@ class ProductBundle(Document): from erpnext.utilities.transaction_base import validate_uom_is_integer validate_uom_is_integer(self, "uom", "qty") + def on_trash(self): + linked_doctypes = ["Delivery Note", "Sales Invoice", "POS Invoice", "Purchase Receipt", "Purchase Invoice", + "Stock Entry", "Stock Reconciliation", "Sales Order", "Purchase Order", "Material Request"] + + invoice_links = [] + for doctype in linked_doctypes: + item_doctype = doctype + " Item" + + if doctype == "Stock Entry": + item_doctype = doctype + " Detail" + + invoices = frappe.db.get_all(item_doctype, {"item_code": self.new_item_code, "docstatus": 1}, ["parent"]) + + for invoice in invoices: + invoice_links.append(get_link_to_form(doctype, invoice['parent'])) + + if len(invoice_links): + frappe.throw( + "This Product Bundle is linked with {0}. You will have to cancel these documents in order to delete this Product Bundle" + .format(", ".join(invoice_links)), title=_("Not Allowed")) + def validate_main_item(self): """Validates, main Item is not a stock item""" if frappe.db.get_value("Item", self.new_item_code, "is_stock_item"): diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 762b6f1d6c9..d31db820abc 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -38,6 +38,8 @@ "col_break46", "shipping_address_name", "shipping_address", + "dispatch_address_name", + "dispatch_address", "customer_group", "territory", "currency_and_price_list", @@ -1486,13 +1488,29 @@ "fieldname": "disable_rounded_total", "fieldtype": "Check", "label": "Disable Rounded Total" + }, + { + "allow_on_submit": 1, + "fieldname": "dispatch_address_name", + "fieldtype": "Link", + "label": "Dispatch Address Name", + "options": "Address", + "print_hide": 1 + }, + { + "allow_on_submit": 1, + "depends_on": "dispatch_address_name", + "fieldname": "dispatch_address", + "fieldtype": "Small Text", + "label": "Dispatch Address", + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2021-04-15 23:55:13.439068", + "modified": "2021-07-08 21:37:44.177493", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 551f715bd52..41f57a34d3d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -233,7 +233,7 @@ class SalesOrder(SellingController): # Checks Sales Invoice submit_rv = frappe.db.sql_list("""select t1.name from `tabSales Invoice` t1,`tabSales Invoice Item` t2 - where t1.name = t2.parent and t2.sales_order = %s and t1.docstatus = 1""", + where t1.name = t2.parent and t2.sales_order = %s and t1.docstatus < 2""", self.name) if submit_rv: diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 987371066a7..974648d6d44 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1217,6 +1217,19 @@ class TestSalesOrder(unittest.TestCase): # To test if the SO does NOT have a Blanket Order self.assertEqual(so_doc.items[0].blanket_order, None) + def test_so_cancellation_when_si_drafted(self): + """ + Test to check if Sales Order gets cancelled if Sales Invoice is in Draft state + Expected result: sales order should not get cancelled + """ + so = make_sales_order() + so.submit() + si = make_sales_invoice(so.name) + si.save() + + self.assertRaises(frappe.ValidationError, so.cancel) + + def make_sales_order(**args): so = frappe.new_doc("Sales Order") 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 750a1a6071d..8d1f112dc28 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -8,39 +8,52 @@ from frappe.utils import cint from erpnext.accounts.doctype.pos_profile.pos_profile import get_item_groups from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_stock_availability -from six import string_types +def search_by_term(search_term, warehouse, price_list): + result = search_for_serial_or_batch_or_barcode_number(search_term) or {} + + item_code = result.get("item_code") or search_term + serial_no = result.get("serial_no") or "" + batch_no = result.get("batch_no") or "" + barcode = result.get("barcode") or "" + + if result: + item_info = frappe.db.get_value("Item", item_code, + ["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"], + as_dict=1) + + item_stock_qty = get_stock_availability(item_code, warehouse) + price_list_rate, currency = frappe.db.get_value('Item Price', { + 'price_list': price_list, + 'item_code': item_code + }, ["price_list_rate", "currency"]) or [None, None] + + item_info.update({ + 'serial_no': serial_no, + 'batch_no': batch_no, + 'barcode': barcode, + 'price_list_rate': price_list_rate, + 'currency': currency, + 'actual_qty': item_stock_qty + }) + + return {'items': [item_info]} @frappe.whitelist() -def get_items(start, page_length, price_list, item_group, pos_profile, search_value=""): - data = dict() +def get_items(start, page_length, price_list, item_group, pos_profile, search_term=""): + warehouse, hide_unavailable_items = frappe.db.get_value( + 'POS Profile', pos_profile, ['warehouse', 'hide_unavailable_items']) + result = [] - allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') - warehouse, hide_unavailable_items = frappe.db.get_value('POS Profile', pos_profile, ['warehouse', 'hide_unavailable_items']) + if search_term: + result = search_by_term(search_term, warehouse, price_list) or [] + if result: + return result if not frappe.db.exists('Item Group', item_group): item_group = get_root_of('Item Group') - if search_value: - data = search_serial_or_batch_or_barcode_number(search_value) - - item_code = data.get("item_code") if data.get("item_code") else search_value - serial_no = data.get("serial_no") if data.get("serial_no") else "" - batch_no = data.get("batch_no") if data.get("batch_no") else "" - barcode = data.get("barcode") if data.get("barcode") else "" - - if data: - item_info = frappe.db.get_value( - "Item", data.get("item_code"), - ["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"] - , as_dict=1) - item_info.setdefault('serial_no', serial_no) - item_info.setdefault('batch_no', batch_no) - item_info.setdefault('barcode', barcode) - - return { 'items': [item_info] } - - condition = get_conditions(item_code, serial_no, batch_no, barcode) + condition = get_conditions(search_term) condition += get_item_group_condition(pos_profile) lft, rgt = frappe.db.get_value('Item Group', item_group, ['lft', 'rgt']) @@ -62,7 +75,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_va `tabItem` item {bin_join_selection} WHERE item.disabled = 0 - AND item.is_stock_item = 1 AND item.has_variants = 0 AND item.is_sales_item = 1 AND item.is_fixed_asset = 0 @@ -84,6 +96,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_va ), {'warehouse': warehouse}, as_dict=1) if items_data: + items_data = filter_service_items(items_data) items = [d.item_code for d in items_data] item_prices_data = frappe.get_all("Item Price", fields = ["item_code", "price_list_rate", "currency"], @@ -96,10 +109,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_va for item in items_data: item_code = item.item_code item_price = item_prices.get(item_code) or {} - if allow_negative_stock: - item_stock_qty = frappe.db.sql("""select ifnull(sum(actual_qty), 0) from `tabBin` where item_code = %s""", item_code)[0][0] - else: - item_stock_qty = get_stock_availability(item_code, warehouse) + item_stock_qty = get_stock_availability(item_code, warehouse) row = {} row.update(item) @@ -110,14 +120,10 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_va }) result.append(row) - res = { - 'items': result - } - - return res + return {'items': result} @frappe.whitelist() -def search_serial_or_batch_or_barcode_number(search_value): +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: @@ -135,27 +141,29 @@ def search_serial_or_batch_or_barcode_number(search_value): return {} -def get_conditions(item_code, serial_no, batch_no, barcode): - if serial_no or batch_no or barcode: - return "item.name = {0}".format(frappe.db.escape(item_code)) +def filter_service_items(items): + for item in items: + if not item['is_stock_item']: + if not frappe.db.exists('Product Bundle', item['item_code']): + items.remove(item) + + return items - return make_condition(item_code) - -def make_condition(item_code): +def get_conditions(search_term): condition = "(" - condition += """item.name like {item_code} - or item.item_name like {item_code}""".format(item_code = frappe.db.escape('%' + item_code + '%')) - condition += add_search_fields_condition(item_code) + condition += """item.name like {search_term} + or item.item_name like {search_term}""".format(search_term=frappe.db.escape('%' + search_term + '%')) + condition += add_search_fields_condition(search_term) condition += ")" return condition -def add_search_fields_condition(item_code): +def add_search_fields_condition(search_term): condition = '' search_fields = frappe.get_all('POS Search Fields', fields = ['fieldname']) if search_fields: for field in search_fields: - condition += " or item.{0} like {1}".format(field['fieldname'], frappe.db.escape('%' + item_code + '%')) + condition += " or item.`{0}` like {1}".format(field['fieldname'], frappe.db.escape('%' + search_term + '%')) return condition def get_item_group_condition(pos_profile): diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 4f4f1b2240b..c827368dbf5 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -241,10 +241,8 @@ erpnext.PointOfSale.Controller = class { events: { get_frm: () => this.frm, - cart_item_clicked: (item_code, batch_no, uom) => { - const search_field = batch_no ? 'batch_no' : 'item_code'; - const search_value = batch_no || item_code; - const item_row = this.frm.doc.items.find(i => i[search_field] === search_value && i.uom === uom); + cart_item_clicked: (item) => { + const item_row = this.get_item_from_frm(item); this.item_details.toggle_item_details_section(item_row); }, @@ -275,18 +273,23 @@ erpnext.PointOfSale.Controller = class { this.cart.toggle_numpad(minimize); }, - form_updated: async (cdt, cdn, fieldname, value) => { - const item_row = frappe.model.get_doc(cdt, cdn); - if (item_row && item_row[fieldname] != value) { - - const { item_code, batch_no, uom } = this.item_details.current_item; - const event = { - field: fieldname, + form_updated: (item, field, value) => { + const item_row = frappe.model.get_doc(item.doctype, item.name); + if (item_row && item_row[field] != value) { + const args = { + field, value, - item: { item_code, batch_no, uom } - } - return this.on_cart_update(event) + item: this.item_details.current_item + }; + return this.on_cart_update(args); } + + return Promise.resolve(); + }, + + highlight_cart_item: (item) => { + const cart_item = this.cart.get_cart_item(item); + this.cart.toggle_item_highlight(cart_item); }, item_field_focused: (fieldname) => { @@ -295,19 +298,18 @@ erpnext.PointOfSale.Controller = class { set_value_in_current_cart_item: (selector, value) => { this.cart.update_selector_value_in_cart_item(selector, value, this.item_details.current_item); }, - clone_new_batch_item_in_frm: (batch_serial_map, current_item) => { + clone_new_batch_item_in_frm: (batch_serial_map, item) => { // called if serial nos are 'auto_selected' and if those serial nos belongs to multiple batches // for each unique batch new item row is added in the form & cart Object.keys(batch_serial_map).forEach(batch => { - const { item_code, batch_no } = current_item; - const item_to_clone = this.frm.doc.items.find(i => i.item_code === item_code && i.batch_no === batch_no); + const item_to_clone = this.frm.doc.items.find(i => i.name == item.name); const new_row = this.frm.add_child("items", { ...item_to_clone }); // update new serialno and batch new_row.batch_no = batch; new_row.serial_no = batch_serial_map[batch].join(`\n`); new_row.qty = batch_serial_map[batch].length; this.frm.doc.items.forEach(row => { - if (item_code === row.item_code) { + if (item.item_code === row.item_code) { this.update_cart_html(row); } }); @@ -316,8 +318,8 @@ erpnext.PointOfSale.Controller = class { remove_item_from_cart: () => this.remove_item_from_cart(), get_item_stock_map: () => this.item_stock_map, close_item_details: () => { - this.item_details.toggle_item_details_section(undefined); - this.cart.prev_action = undefined; + this.item_details.toggle_item_details_section(null); + this.cart.prev_action = null; this.cart.toggle_item_highlight(); }, get_available_stock: (item_code, warehouse) => this.get_available_stock(item_code, warehouse) @@ -501,58 +503,60 @@ erpnext.PointOfSale.Controller = class { let item_row = undefined; try { let { field, value, item } = args; - const { item_code, batch_no, serial_no, uom } = item; - item_row = this.get_item_from_frm(item_code, batch_no, uom); + item_row = this.get_item_from_frm(item); + const item_row_exists = !$.isEmptyObject(item_row); - const item_selected_from_selector = field === 'qty' && value === "+1" + const from_selector = field === 'qty' && value === "+1"; + if (from_selector) + value = flt(item_row.qty) + flt(value); - if (item_row) { - item_selected_from_selector && (value = item_row.qty + flt(value)) - - field === 'qty' && (value = flt(value)); + if (item_row_exists) { + if (field === 'qty') + value = flt(value); if (['qty', 'conversion_factor'].includes(field) && value > 0 && !this.allow_negative_stock) { const qty_needed = field === 'qty' ? value * item_row.conversion_factor : item_row.qty * value; await this.check_stock_availability(item_row, qty_needed, this.frm.doc.set_warehouse); } - if (this.is_current_item_being_edited(item_row) || item_selected_from_selector) { + if (this.is_current_item_being_edited(item_row) || from_selector) { await frappe.model.set_value(item_row.doctype, item_row.name, field, value); this.update_cart_html(item_row); } } else { - if (!this.frm.doc.customer) { - frappe.dom.unfreeze(); - frappe.show_alert({ - message: __('You must select a customer before adding an item.'), - indicator: 'orange' - }); - frappe.utils.play_sound("error"); + if (!this.frm.doc.customer) + return this.raise_customer_selection_alert(); + + const { item_code, batch_no, serial_no, rate } = item; + + if (!item_code) return; - } - if (!item_code) return; - item_selected_from_selector && (value = flt(value)) - - const args = { item_code, batch_no, [field]: value }; + const new_item = { item_code, batch_no, rate, [field]: value }; if (serial_no) { await this.check_serial_no_availablilty(item_code, this.frm.doc.set_warehouse, serial_no); - args['serial_no'] = serial_no; + new_item['serial_no'] = serial_no; } - if (field === 'serial_no') args['qty'] = value.split(`\n`).length || 0; + if (field === 'serial_no') + new_item['qty'] = value.split(`\n`).length || 0; - item_row = this.frm.add_child('items', args); + item_row = this.frm.add_child('items', new_item); if (field === 'qty' && value !== 0 && !this.allow_negative_stock) await this.check_stock_availability(item_row, value, this.frm.doc.set_warehouse); await this.trigger_new_item_events(item_row); - - this.check_serial_batch_selection_needed(item_row) && this.edit_item_details_of(item_row); + this.update_cart_html(item_row); + + if (this.item_details.$component.is(':visible')) + this.edit_item_details_of(item_row); + + if (this.check_serial_batch_selection_needed(item_row)) + this.edit_item_details_of(item_row); } } catch (error) { @@ -563,13 +567,33 @@ erpnext.PointOfSale.Controller = class { } } - get_item_from_frm(item_code, batch_no, uom) { - const has_batch_no = batch_no; - return this.frm.doc.items.find( - i => i.item_code === item_code - && (!has_batch_no || (has_batch_no && i.batch_no === batch_no)) - && (i.uom === uom) - ); + raise_customer_selection_alert() { + frappe.dom.unfreeze(); + frappe.show_alert({ + message: __('You must select a customer before adding an item.'), + indicator: 'orange' + }); + frappe.utils.play_sound("error"); + } + + get_item_from_frm({ name, item_code, batch_no, uom, rate }) { + let item_row = null; + if (name) { + item_row = this.frm.doc.items.find(i => i.name == name); + } else { + // if item is clicked twice from item selector + // then "item_code, batch_no, uom, rate" will help in getting the exact item + // to increase the qty by one + const has_batch_no = batch_no; + item_row = this.frm.doc.items.find( + i => i.item_code === item_code + && (!has_batch_no || (has_batch_no && i.batch_no === batch_no)) + && (i.uom === uom) + && (i.rate == rate) + ); + } + + return item_row || {}; } edit_item_details_of(item_row) { @@ -577,9 +601,7 @@ erpnext.PointOfSale.Controller = class { } is_current_item_being_edited(item_row) { - const { item_code, batch_no } = this.item_details.current_item; - - return item_code !== item_row.item_code || batch_no != item_row.batch_no ? false : true; + return item_row.name == this.item_details.current_item.name; } update_cart_html(item_row, remove_item) { @@ -661,7 +683,7 @@ erpnext.PointOfSale.Controller = class { update_item_field(value, field_or_action) { if (field_or_action === 'checkout') { - this.item_details.toggle_item_details_section(undefined); + this.item_details.toggle_item_details_section(null); } else if (field_or_action === 'remove') { this.remove_item_from_cart(); } else { @@ -680,7 +702,7 @@ erpnext.PointOfSale.Controller = class { .then(() => { frappe.model.clear_doc(doctype, name); this.update_cart_html(current_item, true); - this.item_details.toggle_item_details_section(undefined); + this.item_details.toggle_item_details_section(null); frappe.dom.unfreeze(); }) .catch(e => console.log(e)); diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 11a63b3d4a6..6e36d2809ae 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -181,10 +181,8 @@ erpnext.PointOfSale.ItemCart = class { me.$totals_section.find(".edit-cart-btn").click(); } - const item_code = unescape($cart_item.attr('data-item-code')); - const batch_no = unescape($cart_item.attr('data-batch-no')); - const uom = unescape($cart_item.attr('data-uom')); - me.events.cart_item_clicked(item_code, batch_no, uom); + const item_row_name = unescape($cart_item.attr('data-row-name')); + me.events.cart_item_clicked({ name: item_row_name }); this.numpad_value = ''; }); @@ -369,15 +367,16 @@ erpnext.PointOfSale.ItemCart = class { `
    ` ); const me = this; + const frm = me.events.get_frm(); + let discount = frm.doc.additional_discount_percentage; this.discount_field = frappe.ui.form.make_control({ df: { label: __('Discount'), fieldtype: 'Data', - placeholder: __('Enter discount percentage.'), + placeholder: ( discount ? discount + '%' : __('Enter discount percentage.') ), input_class: 'input-xs', onchange: function() { - const frm = me.events.get_frm(); if (flt(this.value) != 0) { frappe.model.set_value(frm.doc.doctype, frm.doc.name, 'additional_discount_percentage', flt(this.value)); me.hide_discount_control(this.value); @@ -474,12 +473,7 @@ erpnext.PointOfSale.ItemCart = class { const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? frm.doc.grand_total : frm.doc.rounded_total; this.render_grand_total(grand_total); - const taxes = frm.doc.taxes.map(t => { - return { - description: t.description, rate: t.rate - }; - }); - this.render_taxes(frm.doc.total_taxes_and_charges, taxes); + this.render_taxes(frm.doc.taxes); } render_net_total(value) { @@ -504,14 +498,14 @@ erpnext.PointOfSale.ItemCart = class { ); } - render_taxes(value, taxes) { + render_taxes(taxes) { if (taxes.length) { const currency = this.events.get_frm().doc.currency; const taxes_html = taxes.map(t => { const description = /[0-9]+/.test(t.description) ? t.description : `${t.description} @ ${t.rate}%`; return `
    ${description}
    -
    ${format_currency(value, currency)}
    +
    ${format_currency(t.tax_amount_after_discount_amount, currency)}
    `; }).join(''); this.$totals_section.find('.taxes-container').css('display', 'flex').html(taxes_html); @@ -520,28 +514,23 @@ erpnext.PointOfSale.ItemCart = class { } } - get_cart_item({ item_code, batch_no, uom }) { - const batch_attr = `[data-batch-no="${escape(batch_no)}"]`; - const item_code_attr = `[data-item-code="${escape(item_code)}"]`; - const uom_attr = `[data-uom="${escape(uom)}"]`; - - const item_selector = batch_no ? - `.cart-item-wrapper${batch_attr}${uom_attr}` : `.cart-item-wrapper${item_code_attr}${uom_attr}`; - + get_cart_item({ name }) { + const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`; return this.$cart_items_wrapper.find(item_selector); } + get_item_from_frm(item) { + const doc = this.events.get_frm().doc; + return doc.items.find(i => i.name == item.name); + } + update_item_html(item, remove_item) { const $item = this.get_cart_item(item); if (remove_item) { $item && $item.next().remove() && $item.remove(); } else { - const { item_code, batch_no, uom } = item; - const search_field = batch_no ? 'batch_no' : 'item_code'; - const search_value = batch_no || item_code; - const item_row = this.events.get_frm().doc.items.find(i => i[search_field] === search_value && i.uom === uom); - + const item_row = this.get_item_from_frm(item); this.render_cart_item(item_row, $item); } @@ -557,10 +546,7 @@ erpnext.PointOfSale.ItemCart = class { if (!$item_to_update.length) { this.$cart_items_wrapper.append( - `
    -
    + `
    ` ) $item_to_update = this.get_cart_item(item_data); @@ -635,14 +621,24 @@ erpnext.PointOfSale.ItemCart = class { function get_item_image_html() { const { image, item_name } = item_data; - if (image) { - return `
    ${image}
    `; + if (!me.hide_images && image) { + return ` +
    + ${frappe.get_abbr(item_name)} +
    `; } else { return `
    ${frappe.get_abbr(item_name)}
    `; } } } + handle_broken_image($img) { + const item_abbr = $($img).attr('alt'); + $($img).parent().replaceWith(`
    ${item_abbr}
    `); + } + scroll_to_item($item) { if ($item.length === 0) return; const scrollTop = $item.offset().top - this.$cart_items_wrapper.offset().top + this.$cart_items_wrapper.scrollTop(); @@ -970,8 +966,23 @@ erpnext.PointOfSale.ItemCart = class { }); } + attach_refresh_field_event(frm) { + $(frm.wrapper).off('refresh-fields'); + $(frm.wrapper).on('refresh-fields', () => { + if (frm.doc.items.length) { + frm.doc.items.forEach(item => { + this.update_item_html(item); + }); + } + this.update_totals_section(frm); + }); + } + load_invoice() { const frm = this.events.get_frm(); + + this.attach_refresh_field_event(frm); + this.fetch_customer_details(frm.doc.customer).then(() => { this.events.customer_details_updated(this.customer_info); this.update_customer_section(); diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index 32a4556766a..6a4d3d5214d 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -2,6 +2,7 @@ erpnext.PointOfSale.ItemDetails = class { constructor({ wrapper, events, settings }) { this.wrapper = wrapper; this.events = events; + this.hide_images = settings.hide_images; this.allow_rate_change = settings.allow_rate_change; this.allow_discount_change = settings.allow_discount_change; this.current_item = {}; @@ -54,29 +55,33 @@ erpnext.PointOfSale.ItemDetails = class { this.$dicount_section = this.$component.find('.discount-section'); } + compare_with_current_item(item) { + // returns true if `item` is currently being edited + return item && item.name == this.current_item.name; + } + toggle_item_details_section(item) { - const { item_code, batch_no, uom } = this.current_item; - const item_code_is_same = item && item_code === item.item_code; - const batch_is_same = item && batch_no == item.batch_no; - const uom_is_same = item && uom === item.uom; + const current_item_changed = !this.compare_with_current_item(item); - this.item_has_changed = !item ? false : item_code_is_same && batch_is_same && uom_is_same ? false : true; + // if item is null or highlighted cart item is clicked twice + const hide_item_details = !Boolean(item) || !current_item_changed; + + this.events.toggle_item_selector(!hide_item_details); + this.toggle_component(!hide_item_details); - this.events.toggle_item_selector(this.item_has_changed); - this.toggle_component(this.item_has_changed); - - if (this.item_has_changed) { + if (item && current_item_changed) { this.doctype = item.doctype; this.item_meta = frappe.get_meta(this.doctype); this.name = item.name; this.item_row = item; this.currency = this.events.get_frm().doc.currency; - this.current_item = { item_code: item.item_code, batch_no: item.batch_no, uom: item.uom }; + this.current_item = item; this.render_dom(item); this.render_discount_dom(item); this.render_form(item); + this.events.highlight_cart_item(item); } else { this.validate_serial_batch_item(); this.current_item = {}; @@ -120,14 +125,25 @@ erpnext.PointOfSale.ItemDetails = class { this.$item_name.html(item_name); this.$item_description.html(get_description_html()); this.$item_price.html(format_currency(price_list_rate, this.currency)); - if (image) { - this.$item_image.html(`${image}`); + if (!this.hide_images && image) { + this.$item_image.html( + `${frappe.get_abbr(item_name)}` + ); } else { this.$item_image.html(`
    ${frappe.get_abbr(item_name)}
    `); } } + handle_broken_image($img) { + const item_abbr = $($img).attr('alt'); + $($img).replaceWith(`
    ${item_abbr}
    `); + } + render_discount_dom(item) { if (item.discount_percentage) { this.$dicount_section.html( @@ -157,7 +173,7 @@ erpnext.PointOfSale.ItemDetails = class { df: { ...field_meta, onchange: function() { - me.events.form_updated(me.doctype, me.name, fieldname, this.value); + me.events.form_updated(me.current_item, fieldname, this.value); } }, parent: this.$form_container.find(`.${fieldname}-control`), @@ -195,20 +211,17 @@ erpnext.PointOfSale.ItemDetails = class { bind_custom_control_change_event() { const me = this; if (this.rate_control) { - if (this.allow_rate_change) { - this.rate_control.df.onchange = function() { - if (this.value || flt(this.value) === 0) { - me.events.form_updated(me.doctype, me.name, 'rate', this.value).then(() => { - const item_row = frappe.get_doc(me.doctype, me.name); - const doc = me.events.get_frm().doc; - me.$item_price.html(format_currency(item_row.rate, doc.currency)); - me.render_discount_dom(item_row); - }); - } - }; - } else { - this.rate_control.df.read_only = 1; - } + this.rate_control.df.onchange = function() { + if (this.value || flt(this.value) === 0) { + me.events.form_updated(me.current_item, 'rate', this.value).then(() => { + const item_row = frappe.get_doc(me.doctype, me.name); + const doc = me.events.get_frm().doc; + me.$item_price.html(format_currency(item_row.rate, doc.currency)); + me.render_discount_dom(item_row); + }); + } + }; + this.rate_control.df.read_only = !this.allow_rate_change; this.rate_control.refresh(); } @@ -221,7 +234,7 @@ erpnext.PointOfSale.ItemDetails = class { this.warehouse_control.df.reqd = 1; this.warehouse_control.df.onchange = function() { if (this.value) { - me.events.form_updated(me.doctype, me.name, 'warehouse', this.value).then(() => { + me.events.form_updated(me.current_item, 'warehouse', this.value).then(() => { me.item_stock_map = me.events.get_item_stock_map(); const available_qty = me.item_stock_map[me.item_row.item_code][this.value]; if (available_qty === undefined) { @@ -253,7 +266,7 @@ erpnext.PointOfSale.ItemDetails = class { this.serial_no_control.df.reqd = 1; this.serial_no_control.df.onchange = async function() { !me.current_item.batch_no && await me.auto_update_batch_no(); - me.events.form_updated(me.doctype, me.name, 'serial_no', this.value); + me.events.form_updated(me.current_item, 'serial_no', this.value); } this.serial_no_control.refresh(); } @@ -270,19 +283,12 @@ erpnext.PointOfSale.ItemDetails = class { } } }; - this.batch_no_control.df.onchange = function() { - me.events.set_value_in_current_cart_item('batch-no', this.value); - me.events.form_updated(me.doctype, me.name, 'batch_no', this.value); - me.current_item.batch_no = this.value; - } this.batch_no_control.refresh(); } if (this.uom_control) { this.uom_control.df.onchange = function() { - me.events.set_value_in_current_cart_item('uom', this.value); - me.events.form_updated(me.doctype, me.name, 'uom', this.value); - me.current_item.uom = this.value; + me.events.form_updated(me.current_item, 'uom', this.value); const item_row = frappe.get_doc(me.doctype, me.name); me.conversion_factor_control.df.read_only = (item_row.stock_uom == this.value); @@ -292,13 +298,9 @@ erpnext.PointOfSale.ItemDetails = class { frappe.model.on("POS Invoice Item", "*", (fieldname, value, item_row) => { const field_control = this[`${fieldname}_control`]; - const { item_code, batch_no, uom } = this.current_item; - const item_code_is_same = item_code === item_row.item_code; - const batch_is_same = batch_no == item_row.batch_no; - const uom_is_same = uom === item_row.uom; - const item_is_same = item_code_is_same && batch_is_same && uom_is_same ? true : false; + const item_row_is_being_edited = this.compare_with_current_item(item_row); - if (item_is_same && field_control && field_control.get_value() !== value) { + if (item_row_is_being_edited && field_control && field_control.get_value() !== value) { field_control.set_value(value); cur_pos.update_cart_html(item_row); } @@ -316,7 +318,9 @@ erpnext.PointOfSale.ItemDetails = class { fields: ["batch_no", "name"] }); const batch_serial_map = serials_with_batch_no.reduce((acc, r) => { - acc[r.batch_no] || (acc[r.batch_no] = []); + if (!acc[r.batch_no]) { + acc[r.batch_no] = []; + } acc[r.batch_no] = [...acc[r.batch_no], r.name]; return acc; }, {}); @@ -332,12 +336,10 @@ erpnext.PointOfSale.ItemDetails = class { if (serial_nos_belongs_to_other_batch) { this.serial_no_control.set_value(batch_serial_nos); this.qty_control.set_value(batch_serial_map[batch_no].length); - } - delete batch_serial_map[batch_no]; - - if (serial_nos_belongs_to_other_batch) + delete batch_serial_map[batch_no]; this.events.clone_new_batch_item_in_frm(batch_serial_map, this.current_item); + } } } 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 b8a82a9edab..dd7f143c4cb 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -51,7 +51,7 @@ erpnext.PointOfSale.ItemSelector = class { }); } - get_items({start = 0, page_length = 40, search_value=''}) { + get_items({start = 0, page_length = 40, search_term=''}) { const doc = this.events.get_frm().doc; const price_list = (doc && doc.selling_price_list) || this.price_list; let { item_group, pos_profile } = this; @@ -61,7 +61,7 @@ erpnext.PointOfSale.ItemSelector = class { return frappe.call({ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items", freeze: true, - args: { start, page_length, price_list, item_group, search_value, pos_profile }, + args: { start, page_length, price_list, item_group, search_term, pos_profile }, }); } @@ -78,8 +78,9 @@ erpnext.PointOfSale.ItemSelector = class { get_item_html(item) { const me = this; // eslint-disable-next-line no-unused-vars - const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom } = item; + const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item; const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange"; + const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0; let qty_to_display = actual_qty; @@ -94,7 +95,11 @@ erpnext.PointOfSale.ItemSelector = class { ${qty_to_display}
    - ${frappe.get_abbr(item.item_name)} + ${frappe.get_abbr(item.item_name)}
    `; } else { return `
    @@ -108,6 +113,7 @@ erpnext.PointOfSale.ItemSelector = class { `
    ${get_item_image_html()} @@ -116,12 +122,17 @@ erpnext.PointOfSale.ItemSelector = class {
    ${frappe.ellipsis(item.item_name, 18)}
    -
    ${format_currency(item.price_list_rate, item.currency, 0) || 0}
    +
    ${format_currency(price_list_rate, item.currency, precision) || 0}
    ` ); } + handle_broken_image($img) { + const item_abbr = $($img).attr('alt'); + $($img).parent().replaceWith(`
    ${item_abbr}
    `); + } + make_search_bar() { const me = this; const doc = me.events.get_frm().doc; @@ -213,13 +224,19 @@ erpnext.PointOfSale.ItemSelector = class { let batch_no = unescape($item.attr('data-batch-no')); let serial_no = unescape($item.attr('data-serial-no')); let uom = unescape($item.attr('data-uom')); + let rate = unescape($item.attr('data-rate')); // escape(undefined) returns "undefined" then unescape returns "undefined" batch_no = batch_no === "undefined" ? undefined : batch_no; serial_no = serial_no === "undefined" ? undefined : serial_no; uom = uom === "undefined" ? undefined : uom; + rate = rate === "undefined" ? undefined : rate; - me.events.item_selected({ field: 'qty', value: "+1", item: { item_code, batch_no, serial_no, uom }}); + me.events.item_selected({ + field: 'qty', + value: "+1", + item: { item_code, batch_no, serial_no, uom, rate } + }); me.set_search_value(''); }); @@ -290,7 +307,7 @@ erpnext.PointOfSale.ItemSelector = class { } } - this.get_items({ search_value: search_term }) + this.get_items({ search_term }) .then(({ message }) => { // eslint-disable-next-line no-unused-vars const { items, serial_no, batch_no, barcode } = message; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 600f1604900..f1a166b5230 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -56,7 +56,7 @@ erpnext.PointOfSale.Payment = class { ); let df_events = { onchange: function() { - frm.set_value(this.df.fieldname, this.value); + frm.set_value(this.df.fieldname, this.get_value()); } }; if (df.fieldtype == "Button") { @@ -171,7 +171,7 @@ erpnext.PointOfSale.Payment = class { this.setup_listener_for_payments(); - this.$payment_modes.on('click', '.shortcut', () => { + this.$payment_modes.on('click', '.shortcut', function() { const value = $(this).attr('data-value'); me.selected_mode.set_value(value); }); @@ -481,7 +481,7 @@ erpnext.PointOfSale.Payment = class { const amount = doc.loyalty_amount > 0 ? format_currency(doc.loyalty_amount, doc.currency) : ''; this.$payment_modes.append( `
    -
    +
    Redeem Loyalty Points
    ${amount}
    ${loyalty_program}
    @@ -563,4 +563,4 @@ erpnext.PointOfSale.Payment = class { toggle_component(show) { show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); } -}; \ No newline at end of file +}; diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.css b/erpnext/selling/page/sales_funnel/sales_funnel.css index 89e904fcfc9..455d37cb238 100644 --- a/erpnext/selling/page/sales_funnel/sales_funnel.css +++ b/erpnext/selling/page/sales_funnel/sales_funnel.css @@ -1,3 +1,4 @@ .funnel-wrapper { margin: 15px; + width: 100%; } \ No newline at end of file diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py index f5feb95f1a8..8cb24460f7b 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -59,7 +59,7 @@ def get_data(conditions, filters): IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay, soi.qty, soi.delivered_qty, (soi.qty - soi.delivered_qty) AS pending_qty, - IFNULL(sii.qty, 0) as billed_qty, + IFNULL(SUM(sii.qty), 0) as billed_qty, soi.base_amount as amount, (soi.delivered_qty * soi.base_rate) as delivered_qty_amount, (soi.billed_amt * IFNULL(so.conversion_rate, 1)) as billed_amount, diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index eb02867720c..f515baf31bc 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -26,7 +26,7 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran } }; }); - } + } setup_queries() { var me = this; @@ -85,7 +85,7 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran refresh() { super.refresh(); - + frappe.dynamic_link = {doc: this.frm.doc, fieldname: 'customer', doctype: 'Customer'} this.frm.toggle_display("customer_name", @@ -114,6 +114,10 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran erpnext.utils.set_taxes_from_address(this.frm, "shipping_address_name", "customer_address", "shipping_address_name"); } + dispatch_address_name() { + erpnext.utils.get_address_display(this.frm, "dispatch_address_name", "dispatch_address"); + } + sales_partner() { this.apply_pricing_rule(); } diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index b24048d1cea..d05541b6344 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -269,7 +269,7 @@ erpnext.company.setup_queries = function(frm) { ["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": "Liability"},] + ["unrealized_profit_loss_account", {"root_type": ["in", ["Liability", "Asset"]]}] ], function(i, v) { erpnext.company.set_custom_query(frm, v); }); diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 061986d92d7..e6ec496a65e 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -74,7 +74,7 @@ "stock_received_but_not_billed", "service_received_but_not_billed", "expenses_included_in_valuation", - "fixed_asset_depreciation_settings", + "fixed_asset_defaults", "accumulated_depreciation_account", "depreciation_expense_account", "series_for_depreciation_entry", @@ -83,6 +83,7 @@ "disposal_account", "depreciation_cost_center", "capital_work_in_progress_account", + "repair_and_maintenance_account", "asset_received_but_not_billed", "budget_detail", "exception_budget_approver_role", @@ -519,12 +520,6 @@ "no_copy": 1, "options": "Account" }, - { - "collapsible": 1, - "fieldname": "fixed_asset_depreciation_settings", - "fieldtype": "Section Break", - "label": "Fixed Asset Depreciation Settings" - }, { "fieldname": "accumulated_depreciation_account", "fieldtype": "Link", @@ -734,6 +729,18 @@ "fieldtype": "Link", "label": "Default Payment Discount Account", "options": "Account" + }, + { + "collapsible": 1, + "fieldname": "fixed_asset_defaults", + "fieldtype": "Section Break", + "label": "Fixed Asset Defaults" + }, + { + "fieldname": "repair_and_maintenance_account", + "fieldtype": "Link", + "label": "Repair and Maintenance Account", + "options": "Account" } ], "icon": "fa fa-building", @@ -741,7 +748,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2021-05-07 03:11:28.189740", + "modified": "2021-05-12 16:51:08.187233", "modified_by": "Administrator", "module": "Setup", "name": "Company", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 27e023c1e5b..8755125c810 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -110,7 +110,7 @@ class Company(NestedSet): self.create_default_warehouses() if frappe.flags.country_change: - install_country_fixtures(self.name) + install_country_fixtures(self.name, self.country) self.create_default_tax_template() if not frappe.db.get_value("Department", {"company": self.name}): @@ -291,7 +291,7 @@ class Company(NestedSet): cash = frappe.db.get_value('Mode of Payment', {'type': 'Cash'}, 'name') if cash and self.default_cash_account \ and not frappe.db.get_value('Mode of Payment Account', {'company': self.name, 'parent': cash}): - mode_of_payment = frappe.get_doc('Mode of Payment', cash) + mode_of_payment = frappe.get_doc('Mode of Payment', cash, for_update=True) mode_of_payment.append('accounts', { 'company': self.name, 'default_account': self.default_cash_account @@ -395,7 +395,7 @@ class Company(NestedSet): @frappe.whitelist() def enqueue_replace_abbr(company, old, new): - kwargs = dict(company=company, old=old, new=new) + kwargs = dict(queue="long", company=company, old=old, new=new) frappe.enqueue('erpnext.setup.doctype.company.company.replace_abbr', **kwargs) @@ -407,8 +407,6 @@ def replace_abbr(company, old, new): frappe.only_for("System Manager") - frappe.db.set_value("Company", company, "abbr", new) - def _rename_record(doc): parts = doc[0].rsplit(" - ", 1) if len(parts) == 1 or parts[1].lower() == old.lower(): @@ -419,11 +417,18 @@ def replace_abbr(company, old, new): doc = (d for d in frappe.db.sql("select name from `tab%s` where company=%s" % (dt, '%s'), company)) for d in doc: _rename_record(d) + try: + frappe.db.auto_commit_on_many_writes = 1 + frappe.db.set_value("Company", company, "abbr", new) + for dt in ["Warehouse", "Account", "Cost Center", "Department", + "Sales Taxes and Charges Template", "Purchase Taxes and Charges Template"]: + _rename_records(dt) + frappe.db.commit() - for dt in ["Warehouse", "Account", "Cost Center", "Department", - "Sales Taxes and Charges Template", "Purchase Taxes and Charges Template"]: - _rename_records(dt) - frappe.db.commit() + except Exception: + frappe.log_error(title=_('Abbreviation Rename Error')) + finally: + frappe.db.auto_commit_on_many_writes = 0 def get_name_with_abbr(name, company): @@ -435,16 +440,15 @@ def get_name_with_abbr(name, company): return " - ".join(parts) -def install_country_fixtures(company): - company_doc = frappe.get_doc("Company", company) - path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(company_doc.country)) +def install_country_fixtures(company, country): + path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country)) if os.path.exists(path.encode("utf-8")): try: - module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(company_doc.country)) - frappe.get_attr(module_name)(company_doc, False) + module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country)) + frappe.get_attr(module_name)(company, False) except Exception as e: frappe.log_error() - frappe.throw(_("Failed to setup defaults for country {0}. Please contact support@erpnext.com").format(frappe.bold(company_doc.country))) + frappe.throw(_("Failed to setup defaults for country {0}. Please contact support@erpnext.com").format(frappe.bold(country))) def update_company_current_month_sales(company): diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index db31d6d6992..c46b6cc9bdb 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -8,7 +8,7 @@ from frappe import _ from frappe.utils import nowdate, cint, cstr from frappe.utils.nestedset import NestedSet from frappe.website.website_generator import WebsiteGenerator -from frappe.website.render import clear_cache +from frappe.website.utils import clear_cache from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow from erpnext.shopping_cart.product_info import set_product_info_for_website from erpnext.utilities.product import get_qty_in_stock @@ -87,11 +87,11 @@ class ItemGroup(NestedSet, WebsiteGenerator): if not field_filters: field_filters = {} - # Ensure the query remains within current item group - field_filters['item_group'] = self.name + # Ensure the query remains within current item group & sub group + field_filters['item_group'] = [ig[0] for ig in get_child_groups(self.name)] engine = ProductQuery() - context.items = engine.query(attribute_filters, field_filters, search, start) + context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name) filter_engine = ProductFiltersBuilder(self.name) diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 38f8de7a660..691d331c74f 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -12,14 +12,14 @@ from frappe.desk.notifications import clear_notifications class TransactionDeletionRecord(Document): def validate(self): frappe.only_for('System Manager') - company_obj = frappe.get_doc('Company', self.company) - if frappe.session.user != company_obj.owner and frappe.session.user != 'Administrator': - frappe.throw(_('Transactions can only be deleted by the creator of the Company or the Administrator.'), - frappe.PermissionError) + self.validate_doctypes_to_be_ignored() + + def validate_doctypes_to_be_ignored(self): doctypes_to_be_ignored_list = get_doctypes_to_be_ignored() for doctype in self.doctypes_to_be_ignored: if doctype.doctype_name not in doctypes_to_be_ignored_list: - frappe.throw(_("DocTypes should not be added manually to the 'Excluded DocTypes' table. You are only allowed to remove entries from it. "), title=_("Not Allowed")) + frappe.throw(_("DocTypes should not be added manually to the 'Excluded DocTypes' table. You are only allowed to remove entries from it. "), + title=_("Not Allowed")) def before_submit(self): if not self.doctypes_to_be_ignored: @@ -27,54 +27,9 @@ class TransactionDeletionRecord(Document): self.delete_bins() self.delete_lead_addresses() - - company_obj = frappe.get_doc('Company', self.company) - # reset company values - company_obj.total_monthly_sales = 0 - company_obj.sales_monthly_history = None - company_obj.save() - # Clear notification counts + self.reset_company_values() clear_notifications() - - singles = frappe.get_all('DocType', filters = {'issingle': 1}, pluck = 'name') - tables = frappe.get_all('DocType', filters = {'istable': 1}, pluck = 'name') - doctypes_to_be_ignored_list = singles - for doctype in self.doctypes_to_be_ignored: - doctypes_to_be_ignored_list.append(doctype.doctype_name) - - docfields = frappe.get_all('DocField', - filters = { - 'fieldtype': 'Link', - 'options': 'Company', - 'parent': ['not in', doctypes_to_be_ignored_list]}, - fields=['parent', 'fieldname']) - - for docfield in docfields: - if docfield['parent'] != self.doctype: - no_of_docs = frappe.db.count(docfield['parent'], { - docfield['fieldname'] : self.company - }) - - if no_of_docs > 0: - self.delete_version_log(docfield['parent'], docfield['fieldname']) - self.delete_communications(docfield['parent'], docfield['fieldname']) - - # populate DocTypes table - if docfield['parent'] not in tables: - self.append('doctypes', { - 'doctype_name' : docfield['parent'], - 'no_of_docs' : no_of_docs - }) - - # delete the docs linked with the specified company - frappe.db.delete(docfield['parent'], { - docfield['fieldname'] : self.company - }) - - naming_series = frappe.db.get_value('DocType', docfield['parent'], 'autoname') - if naming_series: - if '#' in naming_series: - self.update_naming_series(naming_series, docfield['parent']) + self.delete_company_transactions() def populate_doctypes_to_be_ignored_table(self): doctypes_to_be_ignored_list = get_doctypes_to_be_ignored() @@ -83,6 +38,111 @@ class TransactionDeletionRecord(Document): 'doctype_name' : doctype }) + def delete_bins(self): + frappe.db.sql("""delete from tabBin where warehouse in + (select name from tabWarehouse where company=%s)""", self.company) + + def delete_lead_addresses(self): + """Delete addresses to which leads are linked""" + leads = frappe.get_all('Lead', filters={'company': self.company}) + leads = ["'%s'" % row.get("name") for row in leads] + addresses = [] + if leads: + addresses = frappe.db.sql_list("""select parent from `tabDynamic Link` where link_name + in ({leads})""".format(leads=",".join(leads))) + + if addresses: + addresses = ["%s" % frappe.db.escape(addr) for addr in addresses] + + frappe.db.sql("""delete from tabAddress where name in ({addresses}) and + name not in (select distinct dl1.parent from `tabDynamic Link` dl1 + inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent + and dl1.link_doctype<>dl2.link_doctype)""".format(addresses=",".join(addresses))) + + frappe.db.sql("""delete from `tabDynamic Link` where link_doctype='Lead' + and parenttype='Address' and link_name in ({leads})""".format(leads=",".join(leads))) + + frappe.db.sql("""update tabCustomer set lead_name=NULL where lead_name in ({leads})""".format(leads=",".join(leads))) + + def reset_company_values(self): + company_obj = frappe.get_doc('Company', self.company) + company_obj.total_monthly_sales = 0 + company_obj.sales_monthly_history = None + company_obj.save() + + def delete_company_transactions(self): + doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list() + docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list) + + tables = self.get_all_child_doctypes() + for docfield in docfields: + if docfield['parent'] != self.doctype: + no_of_docs = self.get_number_of_docs_linked_with_specified_company(docfield['parent'], docfield['fieldname']) + + if no_of_docs > 0: + self.delete_version_log(docfield['parent'], docfield['fieldname']) + self.delete_communications(docfield['parent'], docfield['fieldname']) + self.populate_doctypes_table(tables, docfield['parent'], no_of_docs) + + self.delete_child_tables(docfield['parent'], docfield['fieldname']) + self.delete_docs_linked_with_specified_company(docfield['parent'], docfield['fieldname']) + + naming_series = frappe.db.get_value('DocType', docfield['parent'], 'autoname') + if naming_series: + if '#' in naming_series: + self.update_naming_series(naming_series, docfield['parent']) + + def get_doctypes_to_be_ignored_list(self): + singles = frappe.get_all('DocType', filters = {'issingle': 1}, pluck = 'name') + doctypes_to_be_ignored_list = singles + for doctype in self.doctypes_to_be_ignored: + doctypes_to_be_ignored_list.append(doctype.doctype_name) + + return doctypes_to_be_ignored_list + + def get_doctypes_with_company_field(self, doctypes_to_be_ignored_list): + docfields = frappe.get_all('DocField', + filters = { + 'fieldtype': 'Link', + 'options': 'Company', + 'parent': ['not in', doctypes_to_be_ignored_list]}, + fields=['parent', 'fieldname']) + + return docfields + + def get_all_child_doctypes(self): + return frappe.get_all('DocType', filters = {'istable': 1}, pluck = 'name') + + def get_number_of_docs_linked_with_specified_company(self, doctype, company_fieldname): + return frappe.db.count(doctype, {company_fieldname : self.company}) + + def populate_doctypes_table(self, tables, doctype, no_of_docs): + if doctype not in tables: + self.append('doctypes', { + 'doctype_name' : doctype, + 'no_of_docs' : no_of_docs + }) + + def delete_child_tables(self, doctype, company_fieldname): + parent_docs_to_be_deleted = frappe.get_all(doctype, { + company_fieldname : self.company + }, pluck = 'name') + + child_tables = frappe.get_all('DocField', filters = { + 'fieldtype': 'Table', + 'parent': doctype + }, pluck = 'options') + + for table in child_tables: + frappe.db.delete(table, { + 'parent': ['in', parent_docs_to_be_deleted] + }) + + def delete_docs_linked_with_specified_company(self, doctype, company_fieldname): + frappe.db.delete(doctype, { + company_fieldname : self.company + }) + def update_naming_series(self, naming_series, doctype_name): if '.' in naming_series: prefix, hashes = naming_series.rsplit('.', 1) @@ -111,32 +171,6 @@ class TransactionDeletionRecord(Document): frappe.delete_doc('Communication', communication_names, ignore_permissions=True) - def delete_bins(self): - frappe.db.sql("""delete from tabBin where warehouse in - (select name from tabWarehouse where company=%s)""", self.company) - - def delete_lead_addresses(self): - """Delete addresses to which leads are linked""" - leads = frappe.get_all('Lead', filters={'company': self.company}) - leads = ["'%s'" % row.get("name") for row in leads] - addresses = [] - if leads: - addresses = frappe.db.sql_list("""select parent from `tabDynamic Link` where link_name - in ({leads})""".format(leads=",".join(leads))) - - if addresses: - addresses = ["%s" % frappe.db.escape(addr) for addr in addresses] - - frappe.db.sql("""delete from tabAddress where name in ({addresses}) and - name not in (select distinct dl1.parent from `tabDynamic Link` dl1 - inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent - and dl1.link_doctype<>dl2.link_doctype)""".format(addresses=",".join(addresses))) - - frappe.db.sql("""delete from `tabDynamic Link` where link_doctype='Lead' - and parenttype='Address' and link_name in ({leads})""".format(leads=",".join(leads))) - - frappe.db.sql("""update tabCustomer set lead_name=NULL where lead_name in ({leads})""".format(leads=",".join(leads))) - @frappe.whitelist() def get_doctypes_to_be_ignored(): doctypes_to_be_ignored_list = ['Account', 'Cost Center', 'Warehouse', 'Budget', diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index c7220cbc071..bbee74cafb4 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -39,7 +39,7 @@ def check_setup_wizard_not_completed(): if cint(frappe.db.get_single_value('System Settings', 'setup_complete') or 0): message = """ERPNext can only be installed on a fresh site where the setup wizard is not completed. You can reinstall this site (after saving your data) using: bench --site [sitename] reinstall""" - frappe.throw(message) + frappe.throw(message) # nosemgrep def set_single_defaults(): diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index 58764880330..34af093a231 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -481,37 +481,42 @@ }, "Germany": { + "tax_categories": [ + "Umsatzsteuer", + "Vorsteuer" + ], "chart_of_accounts": { "SKR04 mit Kontonummern": { "sales_tax_templates": [ { - "title": "Umsatzsteuer 19%", + "title": "Umsatzsteuer", + "tax_category": "Umsatzsteuer", + "is_default": 1, "taxes": [ { "account_head": { "account_name": "Umsatzsteuer 19%", "account_number": "3806", "tax_rate": 19.00 - } - } - ] - }, - { - "title": "Umsatzsteuer 7%", - "taxes": [ + }, + "rate": 0.00 + }, { "account_head": { "account_name": "Umsatzsteuer 7%", "account_number": "3801", "tax_rate": 7.00 - } + }, + "rate": 0.00 } ] } ], "purchase_tax_templates": [ { - "title": "Abziehbare Vorsteuer 19%", + "title": "Vorsteuer", + "tax_category": "Vorsteuer", + "is_default": 1, "taxes": [ { "account_head": { @@ -519,20 +524,17 @@ "account_number": "1406", "root_type": "Asset", "tax_rate": 19.00 - } - } - ] - }, - { - "title": "Abziehbare Vorsteuer 7%", - "taxes": [ + }, + "rate": 0.00 + }, { "account_head": { "account_name": "Abziehbare Vorsteuer 7%", "account_number": "1401", "root_type": "Asset", "tax_rate": 7.00 - } + }, + "rate": 0.00 } ] }, @@ -559,38 +561,129 @@ } ] } - ] - }, - "SKR03 mit Kontonummern": { - "sales_tax_templates": [ + ], + "item_tax_templates": [ { "title": "Umsatzsteuer 19%", "taxes": [ { - "account_head": { + "tax_type": { "account_name": "Umsatzsteuer 19%", - "account_number": "1776", + "account_number": "3806", "tax_rate": 19.00 - } + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "account_number": "3801", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 } ] }, { "title": "Umsatzsteuer 7%", "taxes": [ + { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "account_number": "3806", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "account_number": "3801", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 + } + ] + }, + { + "title": "Vorsteuer 19%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1406", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1401", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 + } + ] + }, + { + "title": "Vorsteuer 7%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1406", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1401", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 + } + ] + } + ] + }, + "SKR03 mit Kontonummern": { + "sales_tax_templates": [ + { + "title": "Umsatzsteuer", + "tax_category": "Umsatzsteuer", + "is_default": 1, + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 19%", + "account_number": "1776", + "tax_rate": 19.00 + }, + "rate": 0.00 + }, { "account_head": { "account_name": "Umsatzsteuer 7%", "account_number": "1771", "tax_rate": 7.00 - } + }, + "rate": 0.00 } ] } ], "purchase_tax_templates": [ { - "title": "Abziehbare Vorsteuer 19%", + "title": "Vorsteuer", + "tax_category": "Vorsteuer", + "is_default": 1, "taxes": [ { "account_head": { @@ -598,20 +691,107 @@ "account_number": "1576", "root_type": "Asset", "tax_rate": 19.00 - } - } - ] - }, - { - "title": "Abziehbare Vorsteuer 7%", - "taxes": [ + }, + "rate": 0.00 + }, { "account_head": { "account_name": "Abziehbare Vorsteuer 7%", "account_number": "1571", "root_type": "Asset", "tax_rate": 7.00 - } + }, + "rate": 0.00 + } + ] + } + ], + "item_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "account_number": "1776", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "account_number": "1771", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 + } + ] + }, + { + "title": "Umsatzsteuer 7%", + "taxes": [ + { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "account_number": "1776", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "account_number": "1771", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 + } + ] + }, + { + "title": "Vorsteuer 19%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1576", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1571", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 + } + ] + }, + { + "title": "Vorsteuer 7%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1576", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1571", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 } ] } @@ -620,33 +800,34 @@ "Standard with Numbers": { "sales_tax_templates": [ { - "title": "Umsatzsteuer 19%", + "title": "Umsatzsteuer", + "tax_category": "Umsatzsteuer", + "is_default": 1, "taxes": [ { "account_head": { "account_name": "Umsatzsteuer 19%", "account_number": "2301", "tax_rate": 19.00 - } - } - ] - }, - { - "title": "Umsatzsteuer 7%", - "taxes": [ + }, + "rate": 0.00 + }, { "account_head": { "account_name": "Umsatzsteuer 7%", "account_number": "2302", "tax_rate": 7.00 - } + }, + "rate": 0.00 } ] } ], "purchase_tax_templates": [ { - "title": "Abziehbare Vorsteuer 19%", + "title": "Vorsteuer", + "tax_category": "Vorsteuer", + "is_default": 1, "taxes": [ { "account_head": { @@ -654,20 +835,107 @@ "account_number": "1501", "root_type": "Asset", "tax_rate": 19.00 - } - } - ] - }, - { - "title": "Abziehbare Vorsteuer 7%", - "taxes": [ + }, + "rate": 0.00 + }, { "account_head": { "account_name": "Abziehbare Vorsteuer 7%", "account_number": "1502", "root_type": "Asset", "tax_rate": 7.00 - } + }, + "rate": 0.00 + } + ] + } + ], + "item_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "account_number": "2301", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "account_number": "2302", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 + } + ] + }, + { + "title": "Umsatzsteuer 7%", + "taxes": [ + { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "account_number": "2301", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "account_number": "2302", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 + } + ] + }, + { + "title": "Vorsteuer 19%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1501", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1502", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 + } + ] + }, + { + "title": "Vorsteuer 7%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1501", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1502", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 } ] } @@ -676,13 +944,69 @@ "*": { "sales_tax_templates": [ { - "title": "Umsatzsteuer 19%", + "title": "Umsatzsteuer", + "tax_category": "Umsatzsteuer", + "is_default": 1, "taxes": [ { "account_head": { "account_name": "Umsatzsteuer 19%", "tax_rate": 19.00 - } + }, + "rate": 0.00 + }, + { + "account_head": { + "account_name": "Umsatzsteuer 7%", + "tax_rate": 7.00 + }, + "rate": 0.00 + } + ] + } + ], + "purchase_tax_templates": [ + { + "title": "Vorsteuer 19%", + "tax_category": "Vorsteuer", + "is_default": 1, + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 19%", + "tax_rate": 19.00, + "root_type": "Asset" + }, + "rate": 0.00 + }, + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 7%", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "rate": 0.00 + } + ] + } + ], + "item_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { + "account_name": "Umsatzsteuer 7%", + "tax_rate": 7.00 + }, + "tax_rate": 0.00 } ] }, @@ -690,36 +1014,60 @@ "title": "Umsatzsteuer 7%", "taxes": [ { - "account_head": { + "tax_type": { + "account_name": "Umsatzsteuer 19%", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { "account_name": "Umsatzsteuer 7%", "tax_rate": 7.00 - } - } - ] - } - ], - "purchase_tax_templates": [ - { - "title": "Abziehbare Vorsteuer 19%", - "taxes": [ - { - "account_head": { - "account_name": "Abziehbare Vorsteuer 19%", - "tax_rate": 19.00, - "root_type": "Asset" - } + }, + "tax_rate": 7.00 } ] }, { - "title": "Abziehbare Vorsteuer 7%", + "title": "Vorsteuer 19%", "taxes": [ { - "account_head": { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 19.00 + }, + { + "tax_type": { "account_name": "Abziehbare Vorsteuer 7%", "root_type": "Asset", "tax_rate": 7.00 - } + }, + "tax_rate": 0.00 + } + ] + }, + { + "title": "Vorsteuer 7%", + "taxes": [ + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 19%", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "tax_rate": 0.00 + }, + { + "tax_type": { + "account_name": "Abziehbare Vorsteuer 7%", + "root_type": "Asset", + "tax_rate": 7.00 + }, + "tax_rate": 7.00 } ] } @@ -816,33 +1164,292 @@ }, "India": { + "tax_categories": [ + { + "title": "In-State", + "is_inter_state": 0, + "gst_state": "" + }, + { + "title": "Out-State", + "is_inter_state": 1, + "gst_state": "" + }, + { + "title": "Reverse Charge In-State", + "is_inter_state": 0, + "gst_state": "" + }, + { + "title": "Reverse Charge Out-State", + "is_inter_state": 1, + "gst_state": "" + }, + { + "title": "Registered Composition", + "is_inter_state": 0, + "gst_state": "" + } + ], "chart_of_accounts": { "*": { "item_tax_templates": [ { - "title": "In State GST", + "title": "GST 9%", "taxes": [ { "tax_type": { - "account_name": "SGST", + "account_name": "Output Tax SGST", "tax_rate": 9.00 } }, { "tax_type": { - "account_name": "CGST", + "account_name": "Output Tax CGST", "tax_rate": 9.00 } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 18.00 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 18.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 18.00, + "root_type": "Asset" + } } ] }, { - "title": "Out of State GST", + "title": "GST 5%", "taxes": [ { "tax_type": { - "account_name": "IGST", - "tax_rate": 18.00 + "account_name": "Output Tax SGST", + "tax_rate": 2.5 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 2.5 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 5.0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 2.5, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 2.5, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 5.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 2.50, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 2.50, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 5.00, + "root_type": "Asset" + } + } + ] + }, + { + "title": "GST 12%", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 6.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 6.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 12.0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 6.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 6.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 12.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 6.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 6.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 12.00, + "root_type": "Asset" + } + } + ] + }, + { + "title": "GST 28%", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 14.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 14.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 28.0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 14.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 14.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 28.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 14.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 14.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 28.00, + "root_type": "Asset" } } ] @@ -881,35 +1488,116 @@ ] } ], - "*": [ + "sales_tax_templates": [ { - "title": "In State GST", + "title": "Output GST In-state", "taxes": [ { "account_head": { - "account_name": "SGST", - "tax_rate": 9.00 + "account_name": "Output Tax SGST", + "tax_rate": 9.00, + "account_type": "Tax" } }, { "account_head": { - "account_name": "CGST", - "tax_rate": 9.00 + "account_name": "Output Tax CGST", + "tax_rate": 9.00, + "account_type": "Tax" } } - ] + ], + "tax_category": "In-State" }, { - "title": "Out of State GST", + "title": "Output GST Out-state", "taxes": [ { "account_head": { - "account_name": "IGST", - "tax_rate": 18.00 + "account_name": "Output Tax IGST", + "tax_rate": 18.00, + "account_type": "Tax" } } - ] + ], + "tax_category": "Out-State" + } + ], + "purchase_tax_templates": [ + { + "title": "Input GST In-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax SGST", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + }, + { + "account_head": { + "account_name": "Input Tax CGST", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "In-State" }, + { + "title": "Input GST Out-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax IGST", + "tax_rate": 18.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "Out-State" + }, + { + "title": "Input GST RCM In-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + }, + { + "account_head": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "Reverse Charge In-State" + }, + { + "title": "Input GST RCM Out-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 18.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "Reverse Charge Out-State" + } + ], + "*": [ { "title": "VAT 5%", "taxes": [ @@ -1001,7 +1689,7 @@ "Italy VAT 4%":{ "account_name": "IVA 4%", "tax_rate": 4.00 - } + } }, "Ivory Coast": { @@ -1519,7 +2207,7 @@ "South Africa": { "South Africa Tax": { "account_name": "VAT", - "tax_rate": 14.00 + "tax_rate": 15.00 } }, diff --git a/erpnext/setup/setup_wizard/operations/company_setup.py b/erpnext/setup/setup_wizard/operations/company_setup.py index 3f0bb146499..4edf9485dc1 100644 --- a/erpnext/setup/setup_wizard/operations/company_setup.py +++ b/erpnext/setup/setup_wizard/operations/company_setup.py @@ -42,29 +42,6 @@ def enable_shopping_cart(args): 'quotation_series': "QTN-", }).insert() -def create_bank_account(args): - if args.get("bank_account"): - company_name = args.get('company_name') - bank_account_group = frappe.db.get_value("Account", - {"account_type": "Bank", "is_group": 1, "root_type": "Asset", - "company": company_name}) - if bank_account_group: - bank_account = frappe.get_doc({ - "doctype": "Account", - 'account_name': args.get("bank_account"), - 'parent_account': bank_account_group, - 'is_group':0, - 'company': company_name, - "account_type": "Bank", - }) - try: - return bank_account.insert() - except RootNotEditable: - frappe.throw(_("Bank account cannot be named as {0}").format(args.get("bank_account"))) - except frappe.DuplicateEntryError: - # bank account same as a CoA entry - pass - def create_email_digest(): from frappe.utils.user import get_system_managers system_managers = get_system_managers(only_name=True) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 7ae81d782ac..cd49a180529 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -448,6 +448,8 @@ def install_defaults(args=None): set_active_domains(args) update_stock_settings() update_shopping_cart_settings(args) + + args.update({"set_default": 1}) create_bank_account(args) def set_global_defaults(args): @@ -479,17 +481,17 @@ def update_stock_settings(): stock_settings.save() def create_bank_account(args): - if not args.bank_account: + if not args.get('bank_account'): return - company_name = args.company_name + company_name = args.get('company_name') bank_account_group = frappe.db.get_value("Account", {"account_type": "Bank", "is_group": 1, "root_type": "Asset", "company": company_name}) if bank_account_group: bank_account = frappe.get_doc({ "doctype": "Account", - 'account_name': args.bank_account, + 'account_name': args.get('bank_account'), 'parent_account': bank_account_group, 'is_group':0, 'company': company_name, @@ -498,10 +500,13 @@ def create_bank_account(args): try: doc = bank_account.insert() - frappe.db.set_value("Company", args.company_name, "default_bank_account", bank_account.name, update_modified=False) + if args.get('set_default'): + frappe.db.set_value("Company", args.get('company_name'), "default_bank_account", bank_account.name, update_modified=False) + + return doc except RootNotEditable: - frappe.throw(_("Bank account cannot be named as {0}").format(args.bank_account)) + frappe.throw(_("Bank account cannot be named as {0}").format(args.get('bank_account'))) except frappe.DuplicateEntryError: # bank account same as a CoA entry pass diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index 50198374417..cbb3dc881fb 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -11,6 +11,9 @@ from frappe import _ def setup_taxes_and_charges(company_name: str, country: str): + if not frappe.db.exists('Company', company_name): + frappe.throw(_('Company {} does not exist yet. Taxes setup aborted.').format(company_name)) + file_path = os.path.join(os.path.dirname(__file__), '..', 'data', 'country_wise_tax.json') with open(file_path, 'r') as json_file: tax_data = json.load(json_file) @@ -23,7 +26,8 @@ def setup_taxes_and_charges(company_name: str, country: str): if 'chart_of_accounts' not in country_wise_tax: country_wise_tax = simple_to_detailed(country_wise_tax) - from_detailed_data(company_name, country_wise_tax.get('chart_of_accounts')) + from_detailed_data(company_name, country_wise_tax) + update_regional_tax_settings(country, company_name) def simple_to_detailed(templates): @@ -74,10 +78,16 @@ def simple_to_detailed(templates): def from_detailed_data(company_name, data): """Create Taxes and Charges Templates from detailed data.""" coa_name = frappe.db.get_value('Company', company_name, 'chart_of_accounts') - tax_templates = data.get(coa_name) or data.get('*') - sales_tax_templates = tax_templates.get('sales_tax_templates') or tax_templates.get('*') - purchase_tax_templates = tax_templates.get('purchase_tax_templates') or tax_templates.get('*') - item_tax_templates = tax_templates.get('item_tax_templates') or tax_templates.get('*') + coa_data = data.get('chart_of_accounts', {}) + tax_templates = coa_data.get(coa_name) or coa_data.get('*', {}) + tax_categories = data.get('tax_categories') + sales_tax_templates = tax_templates.get('sales_tax_templates') or tax_templates.get('*', {}) + purchase_tax_templates = tax_templates.get('purchase_tax_templates') or tax_templates.get('*', {}) + item_tax_templates = tax_templates.get('item_tax_templates') or tax_templates.get('*', {}) + + if tax_categories: + for tax_category in tax_categories: + make_tax_category(tax_category) if sales_tax_templates: for template in sales_tax_templates: @@ -92,6 +102,17 @@ def from_detailed_data(company_name, data): make_item_tax_template(company_name, template) +def update_regional_tax_settings(country, company): + path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country)) + if os.path.exists(path.encode("utf-8")): + try: + module_name = "erpnext.regional.{0}.setup.update_regional_tax_settings".format(frappe.scrub(country)) + frappe.get_attr(module_name)(country, company) + except Exception as e: + # Log error and ignore if failed to setup regional tax settings + frappe.log_error() + pass + def make_taxes_and_charges_template(company_name, doctype, template): template['company'] = company_name template['doctype'] = doctype @@ -121,8 +142,14 @@ def make_taxes_and_charges_template(company_name, doctype, template): if fieldname not in tax_row: tax_row[fieldname] = default_value - return frappe.get_doc(template).insert(ignore_permissions=True) + doc = frappe.get_doc(template) + # Data in country wise json is already pre validated, hence validations can be ignored + # Ingone validations to make doctypes faster + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True) + return doc def make_item_tax_template(company_name, template): """Create an Item Tax Template. @@ -147,8 +174,14 @@ def make_item_tax_template(company_name, template): if 'tax_rate' not in tax_row: tax_row['tax_rate'] = account_data.get('tax_rate') - return frappe.get_doc(template).insert(ignore_permissions=True) + doc = frappe.get_doc(template) + # Data in country wise json is already pre validated, hence validations can be ignored + # Ingone validations to make doctypes faster + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True) + return doc def get_or_create_account(company_name, account): """ @@ -166,8 +199,7 @@ def get_or_create_account(company_name, account): or_filters={ 'account_name': account.get('account_name'), 'account_number': account.get('account_number') - } - ) + }) if existing_accounts: return frappe.get_doc('Account', existing_accounts[0].name) @@ -182,8 +214,11 @@ def get_or_create_account(company_name, account): account['root_type'] = root_type account['is_group'] = 0 - return frappe.get_doc(account).insert(ignore_permissions=True, ignore_mandatory=True) - + doc = frappe.get_doc(account) + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True, ignore_mandatory=True) + return doc def get_or_create_tax_group(company_name, root_type): # Look for a group account of type 'Tax' @@ -228,8 +263,23 @@ def get_or_create_tax_group(company_name, root_type): 'account_type': 'Tax', 'account_name': account_name, 'parent_account': root_account.name - }).insert(ignore_permissions=True) + }) + + tax_group_account.flags.ignore_links = True + tax_group_account.flags.ignore_validate = True + tax_group_account.insert(ignore_permissions=True) tax_group_name = tax_group_account.name return tax_group_name + + +def make_tax_category(tax_category): + doctype = 'Tax Category' + if isinstance(tax_category, str): + tax_category = {'title': tax_category} + + tax_category['doctype'] = doctype + if not frappe.db.exists(doctype, tax_category['title']): + doc = frappe.get_doc(tax_category) + doc.insert(ignore_permissions=True) diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index 13269a82823..d5dbd4cc659 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -28,21 +28,21 @@ def before_tests(): from frappe.desk.page.setup_wizard.setup_wizard import setup_complete if not frappe.get_list("Company"): setup_complete({ - "currency" :"USD", - "full_name" :"Test User", - "company_name" :"Wind Power LLC", - "timezone" :"America/New_York", - "company_abbr" :"WP", - "industry" :"Manufacturing", - "country" :"United States", - "fy_start_date" :"2011-01-01", - "fy_end_date" :"2011-12-31", - "language" :"english", - "company_tagline" :"Testing", - "email" :"test@erpnext.com", - "password" :"test", + "currency" :"USD", + "full_name" :"Test User", + "company_name" :"Wind Power LLC", + "timezone" :"America/New_York", + "company_abbr" :"WP", + "industry" :"Manufacturing", + "country" :"United States", + "fy_start_date" :"2021-01-01", + "fy_end_date" :"2021-12-31", + "language" :"english", + "company_tagline" :"Testing", + "email" :"test@erpnext.com", + "password" :"test", "chart_of_accounts" : "Standard", - "domains" : ["Manufacturing"], + "domains" : ["Manufacturing"], }) frappe.db.sql("delete from `tabLeave Allocation`") diff --git a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json index 014f4095c15..6ca3d637da4 100644 --- a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json +++ b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json @@ -11,10 +11,11 @@ "hide_custom": 0, "icon": "settings", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "ERPNext Settings", "links": [], - "modified": "2020-12-01 13:38:37.759596", + "modified": "2021-06-12 01:58:11.399566", "modified_by": "Administrator", "module": "Setup", "name": "ERPNext Settings", @@ -109,6 +110,13 @@ "label": "Domain Settings", "link_to": "Domain Settings", "type": "DocType" + }, + { + "doc_view": "", + "icon": "retail", + "label": "Products Settings", + "link_to": "Products Settings", + "type": "DocType" } ] -} \ No newline at end of file +} diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py index 6c63d8759b4..7dfa09e2d62 100644 --- a/erpnext/shopping_cart/filters.py +++ b/erpnext/shopping_cart/filters.py @@ -22,12 +22,15 @@ class ProductFiltersBuilder: filter_data = [] for df in fields: - filters = {} + filters, or_filters = {}, [] if df.fieldtype == "Link": if self.item_group: - filters['item_group'] = self.item_group + or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] + ]) - values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, distinct="True", pluck=df.fieldname) + values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, or_filters=or_filters, distinct="True", pluck=df.fieldname) else: doctype = df.get_link_doctype() @@ -44,7 +47,9 @@ class ProductFiltersBuilder: values = [d.name for d in frappe.get_all(doctype, filters)] # Remove None - values = values.remove(None) if None in values else values + if None in values: + values.remove(None) + if values: filter_data.append([df, values]) @@ -61,14 +66,18 @@ class ProductFiltersBuilder: for attr_doc in attribute_docs: selected_attributes = [] for attr in attr_doc.item_attribute_values: + or_filters = [] filters= [ ["Item Variant Attribute", "attribute", "=", attr.parent], ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] ] if self.item_group: - filters.append(["item_group", "=", self.item_group]) + or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] + ]) - if frappe.db.get_all("Item", filters, limit=1): + if frappe.db.get_all("Item", filters, or_filters=or_filters, limit=1): selected_attributes.append(attr) if selected_attributes: diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py index 36d446ed0fd..6c92d967d0c 100644 --- a/erpnext/shopping_cart/product_query.py +++ b/erpnext/shopping_cart/product_query.py @@ -22,13 +22,14 @@ class ProductQuery: self.settings = frappe.get_doc("Products Settings") self.cart_settings = frappe.get_doc("Shopping Cart Settings") self.page_length = self.settings.products_per_page or 20 - self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', 'item_group', 'image', 'web_long_description', 'description', 'route'] + self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', + 'item_group', 'image', 'web_long_description', 'description', 'route', 'weightage'] self.filters = [] self.or_filters = [['show_in_website', '=', 1]] if not self.settings.get('hide_variants'): self.or_filters.append(['show_variant_in_website', '=', 1]) - def query(self, attributes=None, fields=None, search_term=None, start=0): + def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): """Summary Args: @@ -44,6 +45,15 @@ class ProductQuery: if search_term: self.build_search_filters(search_term) result = [] + website_item_groups = [] + + # if from item group page consider website item group table + if item_group: + website_item_groups = frappe.db.get_all( + "Item", + fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], + filters=[["Website Item Group", "item_group", "=", item_group]] + ) if attributes: all_items = [] @@ -61,22 +71,39 @@ class ProductQuery: ], or_filters=self.or_filters, start=start, - limit=self.page_length + limit=self.page_length, + order_by="weightage desc" ) items_dict = {item.name: item for item in items} - # TODO: Replace Variants by their parent templates all_items.append(set(items_dict.keys())) result = [items_dict.get(item) for item in list(set.intersection(*all_items))] else: - result = frappe.get_all("Item", fields=self.fields, filters=self.filters, or_filters=self.or_filters, start=start, limit=self.page_length) + result = frappe.get_all( + "Item", + fields=self.fields, + filters=self.filters, + or_filters=self.or_filters, + start=start, + limit=self.page_length, + order_by="weightage desc" + ) + + # Combine results having context of website item groups into item results + if item_group and website_item_groups: + items_list = {row.name for row in result} + for row in website_item_groups: + if row.wig_parent not in items_list: + result.append(row) + + result = sorted(result, key=lambda x: x.get("weightage"), reverse=True) for item in result: product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') if product_info: - item.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None + item.formatted_price = (product_info.get('price') or {}).get('formatted_price') return result @@ -90,7 +117,16 @@ class ProductQuery: if not values: continue - if isinstance(values, list): + # handle multiselect fields in filter addition + meta = frappe.get_meta('Item', cached=True) + df = meta.get_field(field) + if df.fieldtype == 'Table MultiSelect': + child_doctype = df.options + child_meta = frappe.get_meta(child_doctype, cached=True) + fields = child_meta.get("fields") + if fields: + self.filters.append([child_doctype, fields[0].fieldname, 'IN', values]) + elif isinstance(values, list): # If value is a list use `IN` query self.filters.append([field, 'IN', values]) else: diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index 943cb3401ff..fc4cf1dbdb8 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "autoname": "field:batch_id", "creation": "2013-03-05 14:50:38", @@ -25,7 +26,11 @@ "reference_doctype", "reference_name", "section_break_7", - "description" + "description", + "manufacturing_section", + "qty_to_produce", + "column_break_23", + "produced_qty" ], "fields": [ { @@ -160,13 +165,35 @@ "label": "Batch UOM", "options": "UOM", "read_only": 1 + }, + { + "fieldname": "manufacturing_section", + "fieldtype": "Section Break", + "label": "Manufacturing" + }, + { + "fieldname": "qty_to_produce", + "fieldtype": "Float", + "label": "Qty To Produce", + "read_only": 1 + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "fieldname": "produced_qty", + "fieldtype": "Float", + "label": "Produced Qty", + "read_only": 1 } ], "icon": "fa fa-archive", "idx": 1, "image_field": "image", + "links": [], "max_attachments": 5, - "modified": "2020-09-18 17:26:09.703215", + "modified": "2021-07-08 16:22:01.343105", "modified_by": "Administrator", "module": "Stock", "name": "Batch", @@ -190,5 +217,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "batch_id" + "title_field": "batch_id", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 508e17c3409..b6eef6ca484 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -226,13 +226,12 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): return batch.name -def set_batch_nos(doc, warehouse_field, throw=False): +def set_batch_nos(doc, warehouse_field, throw=False, child_table="items"): """Automatically select `batch_no` for outgoing items in item table""" - for d in doc.items: + for d in doc.get(child_table): qty = d.get('stock_qty') or d.get('transfer_qty') or d.get('qty') or 0 - has_batch_no = frappe.db.get_value('Item', d.item_code, 'has_batch_no') warehouse = d.get(warehouse_field, None) - if has_batch_no and warehouse and qty > 0: + if warehouse and qty > 0 and frappe.db.get_value('Item', d.item_code, 'has_batch_no'): if not d.batch_no: d.batch_no = get_batch_no(d.item_code, warehouse, qty, throw, d.serial_no) else: @@ -308,4 +307,9 @@ def validate_serial_no_with_batch(serial_nos, item_code): message = "Serial Nos" if len(serial_nos) > 1 else "Serial No" frappe.throw(_("There is no batch found against the {0}: {1}") - .format(message, serial_no_link)) \ No newline at end of file + .format(message, serial_no_link)) + +def make_batch(args): + if frappe.db.get_value("Item", args.item, "has_batch_no"): + args.doctype = "Batch" + frappe.get_doc(args).insert().name diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 0514bd23942..43642013ce6 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -54,7 +54,7 @@ class Bin(Document): self.reserved_qty = flt(self.reserved_qty) + flt(args.get("reserved_qty")) self.indented_qty = flt(self.indented_qty) + flt(args.get("indented_qty")) self.planned_qty = flt(self.planned_qty) + flt(args.get("planned_qty")) - + self.set_projected_qty() self.db_update() @@ -115,7 +115,7 @@ class Bin(Document): #Get Transferred Entries materials_transferred = frappe.db.sql(""" select - ifnull(sum(transfer_qty),0) + ifnull(sum(CASE WHEN se.is_return = 1 THEN (transfer_qty * -1) ELSE transfer_qty END),0) from `tabStock Entry` se, `tabStock Entry Detail` sed, `tabPurchase Order` po where diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index c3803f19a10..36dfa6d7951 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -78,6 +78,9 @@ frappe.ui.form.on("Delivery Note", { }); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + + frm.set_df_property('packed_items', 'cannot_add_rows', true); + frm.set_df_property('packed_items', 'cannot_delete_rows', true); }, print_without_amount: function(frm) { diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 280fde158f5..dbfeb4a10b7 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -32,6 +32,8 @@ "contact_info", "shipping_address_name", "shipping_address", + "dispatch_address_name", + "dispatch_address", "contact_person", "contact_display", "contact_mobile", @@ -554,8 +556,7 @@ "oldfieldname": "packing_details", "oldfieldtype": "Table", "options": "Packed Item", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "product_bundle_help", @@ -1283,13 +1284,28 @@ "fieldname": "disable_rounded_total", "fieldtype": "Check", "label": "Disable Rounded Total" + }, + { + "fieldname": "dispatch_address_name", + "fieldtype": "Link", + "label": "Dispatch Address Name", + "options": "Address", + "print_hide": 1 + }, + { + "depends_on": "dispatch_address_name", + "fieldname": "dispatch_address", + "fieldtype": "Small Text", + "label": "Dispatch Address", + "print_hide": 1, + "read_only": 1 } ], "icon": "fa fa-truck", "idx": 146, "is_submittable": 1, "links": [], - "modified": "2021-04-15 23:55:49.620641", + "modified": "2021-07-08 21:37:20.802652", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index dd31965face..4808e948fc6 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -129,12 +129,13 @@ class DeliveryNote(SellingController): self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() - if self._action != 'submit' and not self.is_return: - set_batch_nos(self, 'warehouse', True) - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list make_packing_list(self) + if self._action != 'submit' and not self.is_return: + set_batch_nos(self, 'warehouse', throw=True) + set_batch_nos(self, 'warehouse', throw=True, child_table="packed_items") + self.update_current_stock() if not self.installation_status: self.installation_status = 'Not Installed' @@ -181,9 +182,8 @@ class DeliveryNote(SellingController): super(DeliveryNote, self).validate_warehouse() for d in self.get_item_list(): - if frappe.db.get_value("Item", d['item_code'], "is_stock_item") == 1: - if not d['warehouse']: - frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) + if not d['warehouse'] and frappe.db.get_value("Item", d['item_code'], "is_stock_item") == 1: + frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index f08125b199d..04028980473 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -6,8 +6,8 @@ frappe.listview_settings['Delivery Note'] = { return [__("Return"), "gray", "is_return,=,Yes"]; } else if (doc.status === "Closed") { return [__("Closed"), "green", "status,=,Closed"]; - } else if (flt(doc.per_returned, 2) === 100) { - return [__("Return Issued"), "grey", "per_returned,=,100"]; + } else if (doc.status === "Return Issued") { + return [__("Return Issued"), "grey", "status,=,Return Issued"]; } else if (flt(doc.per_billed, 2) < 100) { return [__("To Bill"), "orange", "per_billed,<,100"]; } else if (flt(doc.per_billed, 2) === 100) { diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 0c63df0e225..f981aeb13bb 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -7,7 +7,7 @@ import unittest import frappe import json import frappe.defaults -from frappe.utils import cint, nowdate, nowtime, cstr, add_days, flt, today +from frappe.utils import nowdate, nowtime, cstr, flt from erpnext.stock.stock_ledger import get_previous_sle from erpnext.accounts.utils import get_balance_on from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries @@ -18,9 +18,11 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, SerialNoWa from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation \ import create_stock_reconciliation, set_valuation_method from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order, create_dn_against_so -from erpnext.accounts.doctype.account.test_account import get_inventory_account, create_account +from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse -from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + class TestDeliveryNote(unittest.TestCase): def test_over_billing_against_dn(self): @@ -277,8 +279,6 @@ class TestDeliveryNote(unittest.TestCase): dn.cancel() def test_sales_return_for_non_bundled_items_full(self): - from erpnext.stock.doctype.item.test_item import make_item - company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') make_item("Box", {'is_stock_item': 1}) @@ -741,6 +741,25 @@ class TestDeliveryNote(unittest.TestCase): self.assertEqual(si2.items[0].qty, 2) self.assertEqual(si2.items[1].qty, 1) + + def test_delivery_note_bundle_with_batched_item(self): + batched_bundle = make_item("_Test Batched bundle", {"is_stock_item": 0}) + batched_item = make_item("_Test Batched Item", + {"is_stock_item": 1, "has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TESTBATCH.#####"} + ) + make_product_bundle(parent=batched_bundle.name, items=[batched_item.name]) + make_stock_entry(item_code=batched_item.name, target="_Test Warehouse - _TC", qty=10, basic_rate=42) + + try: + dn = create_delivery_note(item_code=batched_bundle.name, qty=1) + except frappe.ValidationError as e: + if "batch" in str(e).lower(): + self.fail("Batch numbers not getting added to bundled items in DN.") + raise e + + self.assertTrue("TESTBATCH" in dn.packed_items[0].batch_no, "Batch number not added in packed item") + + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") args = frappe._dict(args) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 45e3c21b27d..87bd9e61feb 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -93,17 +93,18 @@ frappe.ui.form.on("Item", { erpnext.item.edit_prices_button(frm); erpnext.item.toggle_attributes(frm); - + if (!frm.doc.is_fixed_asset) { erpnext.item.make_dashboard(frm); } frm.add_custom_button(__('Duplicate'), function() { var new_item = frappe.model.copy_doc(frm.doc); - if(new_item.item_name===new_item.item_code) { + // Duplicate item could have different name, causing "copy paste" error. + if (new_item.item_name===new_item.item_code) { new_item.item_name = null; } - if(new_item.description===new_item.description) { + if (new_item.item_code===new_item.description || new_item.item_code===new_item.description) { new_item.description = null; } frappe.set_route('Form', 'Item', new_item.name); @@ -186,8 +187,6 @@ frappe.ui.form.on("Item", { item_code: function(frm) { if(!frm.doc.item_name) frm.set_value("item_name", frm.doc.item_code); - if(!frm.doc.description) - frm.set_value("description", frm.doc.item_code); }, is_stock_item: function(frm) { @@ -381,7 +380,8 @@ $.extend(erpnext.item, { // Show Stock Levels only if is_stock_item if (frm.doc.is_stock_item) { frappe.require('item-dashboard.bundle.js', function() { - const section = frm.dashboard.add_section('', __("Stock Levels")); + frm.dashboard.parent.find('.stock-levels').remove(); + const section = frm.dashboard.add_section('', __("Stock Levels"), 'stock-levels'); erpnext.item.item_dashboard = new erpnext.stock.ItemDashboard({ parent: section, item_code: frm.doc.name, diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index dbac79465ee..42cc67c5cd4 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -1,8 +1,6 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals - import itertools import json import erpnext @@ -12,17 +10,15 @@ from erpnext.controllers.item_variant import (ItemVariantExistsError, copy_attributes_to_variant, get_variant, make_variant_item_code, validate_item_variant_attributes) from erpnext.setup.doctype.item_group.item_group import (get_parent_item_groups, invalidate_cache_for) from frappe import _, msgprint -from frappe.utils import (cint, cstr, flt, formatdate, get_timestamp, getdate, +from frappe.utils import (cint, cstr, flt, formatdate, getdate, now_datetime, random_string, strip, get_link_to_form, nowtime) from frappe.utils.html_utils import clean_html from frappe.website.doctype.website_slideshow.website_slideshow import \ get_slideshow -from frappe.website.render import clear_cache +from frappe.website.utils import clear_cache from frappe.website.website_generator import WebsiteGenerator -from six import iteritems - class DuplicateReorderRows(frappe.ValidationError): pass @@ -76,8 +72,6 @@ class Item(WebsiteGenerator): if not self.description: self.description = self.item_name - # if self.is_sales_item and not self.get('is_item_from_hub'): - # self.publish_in_hub = 1 def after_insert(self): '''set opening stock and item price''' @@ -128,8 +122,9 @@ class Item(WebsiteGenerator): self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() self.update_show_in_website() + self.validate_item_tax_net_rate_range() - if not self.get("__islocal"): + if not self.is_new(): self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") self.old_website_item_groups = frappe.db.sql_list("""select item_group from `tabWebsite Item Group` @@ -203,7 +198,7 @@ class Item(WebsiteGenerator): def make_route(self): if not self.route: return cstr(frappe.db.get_value('Item Group', self.item_group, - 'route')) + '/' + self.scrub((self.item_name if self.item_name else self.item_code) + '-' + random_string(5)) + 'route')) + '/' + self.scrub((self.item_name or self.item_code) + '-' + random_string(5)) def validate_website_image(self): if frappe.flags.in_import: @@ -258,7 +253,6 @@ class Item(WebsiteGenerator): "attached_to_name": self.name }) except frappe.DoesNotExistError: - pass # cleanup frappe.local.message_log.pop() @@ -362,47 +356,49 @@ class Item(WebsiteGenerator): context.update(get_slideshow(self)) def set_attribute_context(self, context): - if self.has_variants: - attribute_values_available = {} - context.attribute_values = {} - context.selected_attributes = {} + if not self.has_variants: + return - # load attributes - for v in context.variants: - v.attributes = frappe.get_all("Item Variant Attribute", - fields=["attribute", "attribute_value"], - filters={"parent": v.name}) - # make a map for easier access in templates - v.attribute_map = frappe._dict({}) - for attr in v.attributes: - v.attribute_map[attr.attribute] = attr.attribute_value + attribute_values_available = {} + context.attribute_values = {} + context.selected_attributes = {} - for attr in v.attributes: - values = attribute_values_available.setdefault(attr.attribute, []) - if attr.attribute_value not in values: - values.append(attr.attribute_value) + # load attributes + for v in context.variants: + v.attributes = frappe.get_all("Item Variant Attribute", + fields=["attribute", "attribute_value"], + filters={"parent": v.name}) + # make a map for easier access in templates + v.attribute_map = frappe._dict({}) + for attr in v.attributes: + v.attribute_map[attr.attribute] = attr.attribute_value - if v.name == context.variant.name: - context.selected_attributes[attr.attribute] = attr.attribute_value + for attr in v.attributes: + values = attribute_values_available.setdefault(attr.attribute, []) + if attr.attribute_value not in values: + values.append(attr.attribute_value) - # filter attributes, order based on attribute table - for attr in self.attributes: - values = context.attribute_values.setdefault(attr.attribute, []) + if v.name == context.variant.name: + context.selected_attributes[attr.attribute] = attr.attribute_value - if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): - for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): - values.append(val) + # filter attributes, order based on attribute table + for attr in self.attributes: + values = context.attribute_values.setdefault(attr.attribute, []) - else: - # get list of values defined (for sequence) - for attr_value in frappe.db.get_all("Item Attribute Value", - fields=["attribute_value"], - filters={"parent": attr.attribute}, order_by="idx asc"): + if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): + for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): + values.append(val) - if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): - values.append(attr_value.attribute_value) + else: + # get list of values defined (for sequence) + for attr_value in frappe.db.get_all("Item Attribute Value", + fields=["attribute_value"], + filters={"parent": attr.attribute}, order_by="idx asc"): - context.variant_info = json.dumps(context.variants) + if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): + values.append(attr_value.attribute_value) + + context.variant_info = json.dumps(context.variants) def set_disabled_attributes(self, context): """Disable selection options of attribute combinations that do not result in a variant""" @@ -490,6 +486,11 @@ class Item(WebsiteGenerator): if self.disabled: self.show_in_website = False + def validate_item_tax_net_rate_range(self): + for tax in self.get('taxes'): + if flt(tax.maximum_net_rate) < flt(tax.minimum_net_rate): + frappe.throw(_("Row #{0}: Maximum Net Rate cannot be greater than Minimum Net Rate")) + def update_template_tables(self): template = frappe.get_doc("Item", self.variant_of) @@ -521,7 +522,7 @@ class Item(WebsiteGenerator): def validate_item_type(self): if self.has_serial_no == 1 and self.is_stock_item == 0 and not self.is_fixed_asset: - msgprint(_("'Has Serial No' can not be 'Yes' for non-stock item"), raise_exception=1) + frappe.throw(_("'Has Serial No' can not be 'Yes' for non-stock item")) if self.has_serial_no == 0 and self.serial_no_series: self.serial_no_series = None @@ -542,10 +543,7 @@ class Item(WebsiteGenerator): def fill_customer_code(self): """ Append all the customer codes and insert into "customer_code" field of item table """ - cust_code = [] - for d in self.get('customer_items'): - cust_code.append(d.ref_code) - self.customer_code = ','.join(cust_code) + self.customer_code = ','.join(d.ref_code for d in self.get("customer_items", [])) def check_item_tax(self): """Check whether Tax Rate is not entered twice for same Tax Type""" @@ -742,23 +740,25 @@ class Item(WebsiteGenerator): def update_template_item(self): """Set Show in Website for Template Item if True for its Variant""" - if self.variant_of: - if self.show_in_website: - self.show_variant_in_website = 1 - self.show_in_website = 0 + if not self.variant_of: + return - if self.show_variant_in_website: - # show template - template_item = frappe.get_doc("Item", self.variant_of) + if self.show_in_website: + self.show_variant_in_website = 1 + self.show_in_website = 0 - if not template_item.show_in_website: - template_item.show_in_website = 1 - template_item.flags.dont_update_variants = True - template_item.flags.ignore_permissions = True - template_item.save() + if self.show_variant_in_website: + # show template + template_item = frappe.get_doc("Item", self.variant_of) + + if not template_item.show_in_website: + template_item.show_in_website = 1 + template_item.flags.dont_update_variants = True + template_item.flags.ignore_permissions = True + template_item.save() def validate_item_defaults(self): - companies = list(set([row.company for row in self.item_defaults])) + companies = {row.company for row in self.item_defaults} if len(companies) != len(self.item_defaults): frappe.throw(_("Cannot set multiple Item Defaults for a company.")) @@ -813,7 +813,7 @@ class Item(WebsiteGenerator): frappe.throw(_("Item has variants.")) def validate_attributes_in_variants(self): - if not self.has_variants or self.get("__islocal"): + if not self.has_variants or self.is_new(): return old_doc = self.get_doc_before_save() @@ -901,7 +901,7 @@ class Item(WebsiteGenerator): frappe.throw(_("Variant Based On cannot be changed")) def validate_uom(self): - if not self.get("__islocal"): + if not self.is_new(): check_stock_uom_with_bin(self.name, self.stock_uom) if self.has_variants: for d in frappe.db.get_all("Item", filters={"variant_of": self.name}): @@ -959,20 +959,20 @@ class Item(WebsiteGenerator): d.variant_of = self.variant_of def cant_change(self): - if not self.get("__islocal"): - fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no") + if self.is_new(): + return - values = frappe.db.get_value("Item", self.name, fields, as_dict=True) - 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" + fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no") - if values: - for field in fields: - if cstr(self.get(field)) != cstr(values.get(field)): - if not self.check_if_linked_document_exists(field): - break # no linked document, allowed - else: - 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)))) + values = frappe.db.get_value("Item", self.name, fields, as_dict=True) + 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)))) def check_if_linked_document_exists(self, field): linked_doctypes = ["Delivery Note Item", "Sales Invoice Item", "POS Invoice Item", "Purchase Receipt Item", @@ -1054,56 +1054,42 @@ def make_item_price(item, price_list_name, item_price): }).insert() def get_timeline_data(doctype, name): - '''returns timeline data based on stock ledger entry''' - out = {} - items = dict(frappe.db.sql('''select posting_date, count(*) - from `tabStock Ledger Entry` where item_code=%s - and posting_date > date_sub(curdate(), interval 1 year) - group by posting_date''', name)) + """get timeline data based on Stock Ledger Entry. This is displayed as heatmap on the item page.""" - for date, count in iteritems(items): - timestamp = get_timestamp(date) - out.update({timestamp: count}) + items = frappe.db.sql("""select unix_timestamp(posting_date), count(*) + from `tabStock Ledger Entry` + where item_code=%s and posting_date > date_sub(curdate(), interval 1 year) + group by posting_date""", name) - return out + return dict(items) -def validate_end_of_life(item_code, end_of_life=None, disabled=None, verbose=1): + +def validate_end_of_life(item_code, end_of_life=None, disabled=None): if (not end_of_life) or (disabled is None): end_of_life, disabled = frappe.db.get_value("Item", item_code, ["end_of_life", "disabled"]) if end_of_life and end_of_life != "0000-00-00" and getdate(end_of_life) <= now_datetime().date(): - msg = _("Item {0} has reached its end of life on {1}").format(item_code, formatdate(end_of_life)) - _msgprint(msg, verbose) + frappe.throw(_("Item {0} has reached its end of life on {1}").format(item_code, formatdate(end_of_life))) if disabled: - _msgprint(_("Item {0} is disabled").format(item_code), verbose) + frappe.throw(_("Item {0} is disabled").format(item_code)) -def validate_is_stock_item(item_code, is_stock_item=None, verbose=1): +def validate_is_stock_item(item_code, is_stock_item=None): if not is_stock_item: is_stock_item = frappe.db.get_value("Item", item_code, "is_stock_item") if is_stock_item != 1: - msg = _("Item {0} is not a stock Item").format(item_code) - - _msgprint(msg, verbose) + frappe.throw(_("Item {0} is not a stock Item").format(item_code)) -def validate_cancelled_item(item_code, docstatus=None, verbose=1): +def validate_cancelled_item(item_code, docstatus=None): if docstatus is None: docstatus = frappe.db.get_value("Item", item_code, "docstatus") if docstatus == 2: - msg = _("Item {0} is cancelled").format(item_code) - _msgprint(msg, verbose) - -def _msgprint(msg, verbose): - if verbose: - msgprint(msg, raise_exception=True) - else: - raise frappe.ValidationError(msg) - + frappe.throw(_("Item {0} is cancelled").format(item_code)) def get_last_purchase_details(item_code, doc_name=None, conversion_rate=1.0): """returns last purchase details in stock uom""" @@ -1203,27 +1189,25 @@ def check_stock_uom_with_bin(item, stock_uom): if stock_uom == frappe.db.get_value("Item", item, "stock_uom"): return - matched = True ref_uom = frappe.db.get_value("Stock Ledger Entry", {"item_code": item}, "stock_uom") if ref_uom: if cstr(ref_uom) != cstr(stock_uom): - matched = False - else: - bin_list = frappe.db.sql("select * from tabBin where item_code=%s", item, as_dict=1) - for bin in bin_list: - if (bin.reserved_qty > 0 or bin.ordered_qty > 0 or bin.indented_qty > 0 - or bin.planned_qty > 0) and cstr(bin.stock_uom) != cstr(stock_uom): - matched = False - break + frappe.throw(_("Default Unit of Measure for Item {0} cannot be changed directly because you have already made some transaction(s) with another UOM. You will need to create a new Item to use a different Default UOM.").format(item)) - if matched and bin_list: - frappe.db.sql("""update tabBin set stock_uom=%s where item_code=%s""", (stock_uom, item)) + bin_list = frappe.db.sql(""" + select * from tabBin where item_code = %s + and (reserved_qty > 0 or ordered_qty > 0 or indented_qty > 0 or planned_qty > 0) + and stock_uom != %s + """, (item, stock_uom), as_dict=1) + + if bin_list: + frappe.throw(_("Default Unit of Measure for Item {0} cannot be changed directly because you have already made some transaction(s) with another UOM. You need to either cancel the linked documents or create a new Item.").format(item)) + + # No SLE or documents against item. Bin UOM can be changed safely. + frappe.db.sql("""update tabBin set stock_uom=%s where item_code=%s""", (stock_uom, item)) - if not matched: - frappe.throw( - _("Default Unit of Measure for Item {0} cannot be changed directly because you have already made some transaction(s) with another UOM. You will need to create a new Item to use a different Default UOM.").format(item)) def get_item_defaults(item_code, company): item = frappe.get_cached_doc('Item', item_code) @@ -1264,45 +1248,59 @@ def get_item_details(item_code, company=None): @frappe.whitelist() def get_uom_conv_factor(uom, stock_uom): - uoms = [uom, stock_uom] - value = "" - uom_details = frappe.db.sql("""select to_uom, from_uom, value from `tabUOM Conversion Factor`\ - where to_uom in ({0}) - """.format(', '.join([frappe.db.escape(i, percent=False) for i in uoms])), as_dict=True) + """ Get UOM conversion factor from uom to stock_uom + e.g. uom = "Kg", stock_uom = "Gram" then returns 1000.0 + """ + if uom == stock_uom: + return 1.0 - for d in uom_details: - if d.from_uom == stock_uom and d.to_uom == uom: - value = 1/flt(d.value) - elif d.from_uom == uom and d.to_uom == stock_uom: - value = d.value + from_uom, to_uom = uom, stock_uom # renaming for readability - if not value: - uom_stock = frappe.db.get_value("UOM Conversion Factor", {"to_uom": stock_uom}, ["from_uom", "value"], as_dict=1) - uom_row = frappe.db.get_value("UOM Conversion Factor", {"to_uom": uom}, ["from_uom", "value"], as_dict=1) + exact_match = frappe.db.get_value("UOM Conversion Factor", {"to_uom": to_uom, "from_uom": from_uom}, ["value"], as_dict=1) + if exact_match: + return exact_match.value - if uom_stock and uom_row: - if uom_stock.from_uom == uom_row.from_uom: - value = flt(uom_stock.value) * 1/flt(uom_row.value) + inverse_match = frappe.db.get_value("UOM Conversion Factor", {"to_uom": from_uom, "from_uom": to_uom}, ["value"], as_dict=1) + if inverse_match: + return 1 / inverse_match.value + + # This attempts to try and get conversion from intermediate UOM. + # case: + # g -> mg = 1000 + # g -> kg = 0.001 + # therefore kg -> mg = 1000 / 0.001 = 1,000,000 + intermediate_match = frappe.db.sql(""" + select (first.value / second.value) as value + from `tabUOM Conversion Factor` first + join `tabUOM Conversion Factor` second + on first.from_uom = second.from_uom + where + first.to_uom = %(to_uom)s + and second.to_uom = %(from_uom)s + limit 1 + """, {"to_uom": to_uom, "from_uom": from_uom}, as_dict=1) + + if intermediate_match: + return intermediate_match[0].value - return value @frappe.whitelist() -def get_item_attribute(parent, attribute_value=''): +def get_item_attribute(parent, attribute_value=""): + """Used for providing auto-completions in child table.""" if not frappe.has_permission("Item"): - frappe.msgprint(_("No Permission"), raise_exception=1) + frappe.throw(_("No Permission")) return frappe.get_all("Item Attribute Value", fields = ["attribute_value"], - filters = {'parent': parent, 'attribute_value': ("like", "%%%s%%" % attribute_value)}) + filters = {'parent': parent, 'attribute_value': ("like", f"%{attribute_value}%")}) def update_variants(variants, template, publish_progress=True): - count=0 - for d in variants: + total = len(variants) + for count, d in enumerate(variants, start=1): variant = frappe.get_doc("Item", d) copy_attributes_to_variant(template, variant) variant.save() - count+=1 if publish_progress: - frappe.publish_progress(count*100/len(variants), title = _("Updating Variants...")) + frappe.publish_progress(count / total * 100, title=_("Updating Variants...")) def on_doctype_update(): # since route is a Text column, it needs a length for indexing diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index e0b89d8e451..922049f1447 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -10,14 +10,15 @@ from frappe.test_runner import make_test_objects from erpnext.controllers.item_variant import (create_variant, ItemVariantExistsError, InvalidItemAttributeValueError, get_variant) from erpnext.stock.doctype.item.item import StockExistsForTemplate, InvalidBarcode -from erpnext.stock.doctype.item.item import get_uom_conv_factor +from erpnext.stock.doctype.item.item import (get_uom_conv_factor, get_item_attribute, + validate_is_stock_item, get_timeline_data) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_item_details +from erpnext.tests.utils import change_settings -from six import iteritems test_ignore = ["BOM"] -test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand"] +test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand", "Item Attribute"] def make_item(item_code, properties=None): if frappe.db.exists("Item", item_code): @@ -98,7 +99,7 @@ class TestItem(unittest.TestCase): "ignore_pricing_rule": 1 }) - for key, value in iteritems(to_check): + for key, value in to_check.items(): self.assertEqual(value, details.get(key)) def test_item_tax_template(self): @@ -194,7 +195,7 @@ class TestItem(unittest.TestCase): "plc_conversion_rate": 1, "customer": "_Test Customer", }) - for key, value in iteritems(sales_item_check): + for key, value in sales_item_check.items(): self.assertEqual(value, sales_item_details.get(key)) purchase_item_check = { @@ -215,7 +216,7 @@ class TestItem(unittest.TestCase): "plc_conversion_rate": 1, "supplier": "_Test Supplier", }) - for key, value in iteritems(purchase_item_check): + for key, value in purchase_item_check.items(): self.assertEqual(value, purchase_item_details.get(key)) def test_item_attribute_change_after_variant(self): @@ -375,6 +376,14 @@ class TestItem(unittest.TestCase): self.assertEqual(item_doc.uoms[1].uom, "Kg") self.assertEqual(item_doc.uoms[1].conversion_factor, 1000) + def test_uom_conv_intermediate(self): + factor = get_uom_conv_factor("Pound", "Gram") + self.assertAlmostEqual(factor, 453.592, 3) + + def test_uom_conv_base_case(self): + factor = get_uom_conv_factor("m", "m") + self.assertEqual(factor, 1.0) + def test_item_variant_by_manufacturer(self): fields = [{'field_name': 'description'}, {'field_name': 'variant_based_on'}] set_item_variant_settings(fields) @@ -464,7 +473,7 @@ class TestItem(unittest.TestCase): self.assertEqual(len(matching_barcodes), 1) details = matching_barcodes[0] - for key, value in iteritems(barcode_properties): + for key, value in barcode_properties.items(): self.assertEqual(value, details.get(key)) # Add barcode again - should cause DuplicateEntryError @@ -480,6 +489,89 @@ class TestItem(unittest.TestCase): new_barcode.barcode_type = 'EAN' self.assertRaises(InvalidBarcode, item_doc.save) + def test_heatmap_data(self): + import time + data = get_timeline_data("Item", "_Test Item") + self.assertTrue(isinstance(data, dict)) + + now = time.time() + one_year_ago = now - 366 * 24 * 60 * 60 + + for timestamp, count in data.items(): + self.assertIsInstance(timestamp, int) + self.assertTrue(one_year_ago <= timestamp <= now) + self.assertIsInstance(count, int) + self.assertTrue(count >= 0) + + def test_index_creation(self): + "check if index is getting created in db" + from erpnext.stock.doctype.item.item import on_doctype_update + on_doctype_update() + + indices = frappe.db.sql("show index from tabItem", as_dict=1) + expected_columns = {"item_code", "item_name", "item_group", "route"} + for index in indices: + expected_columns.discard(index.get("Column_name")) + + if expected_columns: + self.fail(f"Expected db index on these columns: {', '.join(expected_columns)}") + + def test_attribute_completions(self): + expected_attrs = {"Small", "Extra Small", "Extra Large", "Large", "2XL", "Medium"} + + attrs = get_item_attribute("Test Size") + received_attrs = {attr.attribute_value for attr in attrs} + self.assertEqual(received_attrs, expected_attrs) + + attrs = get_item_attribute("Test Size", attribute_value="extra") + received_attrs = {attr.attribute_value for attr in attrs} + self.assertEqual(received_attrs, {"Extra Small", "Extra Large"}) + + def test_check_stock_uom_with_bin(self): + # this item has opening stock and stock_uom set in test_records. + item = frappe.get_doc("Item", "_Test Item") + item.stock_uom = "Gram" + self.assertRaises(frappe.ValidationError, item.save) + + def test_check_stock_uom_with_bin_no_sle(self): + from erpnext.stock.stock_balance import update_bin_qty + item = create_item("_Item with bin qty") + item.stock_uom = "Gram" + item.save() + + update_bin_qty(item.item_code, "_Test Warehouse - _TC", { + "reserved_qty": 10 + }) + + item.stock_uom = "Kilometer" + self.assertRaises(frappe.ValidationError, item.save) + + update_bin_qty(item.item_code, "_Test Warehouse - _TC", { + "reserved_qty": 0 + }) + + item.load_from_db() + item.stock_uom = "Kilometer" + try: + item.save() + except frappe.ValidationError as e: + self.fail(f"UoM change not allowed even though no SLE / BIN with positive qty exists: {e}") + + def test_validate_stock_item(self): + self.assertRaises(frappe.ValidationError, validate_is_stock_item, "_Test Non Stock Item") + + try: + validate_is_stock_item("_Test Item") + except frappe.ValidationError as e: + self.fail(f"stock item considered non-stock item: {e}") + + @change_settings("Stock Settings", {"item_naming_by": "Naming Series"}) + def test_autoname_series(self): + item = frappe.new_doc("Item") + item.item_group = "All Item Groups" + item.save() # if item code saved without item_code then series worked + + def set_item_variant_settings(fields): doc = frappe.get_doc('Item Variant Settings') doc.set('fields', fields) @@ -494,23 +586,26 @@ def make_item_variant(): test_records = frappe.get_test_records('Item') -def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None, is_customer_provided_item=None, - customer=None, is_purchase_item=None, opening_stock=None, company=None): +def create_item(item_code, is_stock_item=1, valuation_rate=0, warehouse="_Test Warehouse - _TC", + is_customer_provided_item=None, customer=None, is_purchase_item=None, opening_stock=0, is_fixed_asset=0, + asset_category=None, company="_Test Company"): if not frappe.db.exists("Item", item_code): item = frappe.new_doc("Item") item.item_code = item_code item.item_name = item_code item.description = item_code item.item_group = "All Item Groups" - item.is_stock_item = is_stock_item or 1 - item.opening_stock = opening_stock or 0 - item.valuation_rate = valuation_rate or 0.0 + item.is_stock_item = is_stock_item + item.is_fixed_asset = is_fixed_asset + item.asset_category = asset_category + item.opening_stock = opening_stock + item.valuation_rate = valuation_rate item.is_purchase_item = is_purchase_item item.is_customer_provided_item = is_customer_provided_item item.customer = customer or '' item.append("item_defaults", { - "default_warehouse": warehouse or '_Test Warehouse - _TC', - "company": company or "_Test Company" + "default_warehouse": warehouse, + "company": company }) item.save() else: diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py index d5700fe5147..8f76844bde0 100644 --- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py @@ -18,6 +18,9 @@ class TestItemAlternative(unittest.TestCase): make_items() def test_alternative_item_for_subcontract_rm(self): + frappe.db.set_value('Buying Settings', None, + 'backflush_raw_materials_of_subcontract_based_on', 'BOM') + create_stock_reconciliation(item_code='Alternate Item For A RW 1', warehouse='_Test Warehouse - _TC', qty=5, rate=2000) create_stock_reconciliation(item_code='Test FG A RW 2', warehouse='_Test Warehouse - _TC', @@ -65,6 +68,8 @@ class TestItemAlternative(unittest.TestCase): status = True self.assertEqual(status, True) + frappe.db.set_value('Buying Settings', None, + 'backflush_raw_materials_of_subcontract_based_on', 'Material Transferred for Subcontract') def test_alternative_item_for_production_rm(self): create_stock_reconciliation(item_code='Alternate Item For A RW 1', diff --git a/erpnext/stock/doctype/item_tax/item_tax.json b/erpnext/stock/doctype/item_tax/item_tax.json index ae36efc7e3f..fb100967f3b 100644 --- a/erpnext/stock/doctype/item_tax/item_tax.json +++ b/erpnext/stock/doctype/item_tax/item_tax.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2013-02-22 01:28:01", "doctype": "DocType", "editable_grid": 1, @@ -6,7 +7,9 @@ "field_order": [ "item_tax_template", "tax_category", - "valid_from" + "valid_from", + "minimum_net_rate", + "maximum_net_rate" ], "fields": [ { @@ -33,11 +36,24 @@ "fieldtype": "Date", "in_list_view": 1, "label": "Valid From" + }, + { + "fieldname": "maximum_net_rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Maximum Net Rate" + }, + { + "fieldname": "minimum_net_rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Minimum Net Rate" } ], "idx": 1, "istable": 1, - "modified": "2020-06-25 01:40:28.859752", + "links": [], + "modified": "2021-06-03 13:20:06.982303", "modified_by": "Administrator", "module": "Stock", "name": "Item Tax", diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 5df4d8743fc..bf969f99f8e 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -41,7 +41,7 @@ class LandedCostVoucher(Document): def validate(self): self.check_mandatory() - self.validate_purchase_receipts() + self.validate_receipt_documents() init_landed_taxes_and_totals(self) self.set_total_taxes_and_charges() if not self.get("items"): @@ -56,14 +56,23 @@ class LandedCostVoucher(Document): frappe.throw(_("Please enter Receipt Document")) - def validate_purchase_receipts(self): + def validate_receipt_documents(self): receipt_documents = [] for d in self.get("purchase_receipts"): - if frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus") != 1: - frappe.throw(_("Receipt document must be submitted")) - else: - receipt_documents.append(d.receipt_document) + docstatus = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus") + if docstatus != 1: + msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted" + frappe.throw(_(msg), title=_("Invalid Document")) + + if d.receipt_document_type == "Purchase Invoice": + update_stock = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "update_stock") + if not update_stock: + msg = _("Row {0}: Purchase Invoice {1} has no stock impact.").format(d.idx, frappe.bold(d.receipt_document)) + msg += "
    " + _("Please create Landed Cost Vouchers against Invoices that have 'Update Stock' enabled.") + frappe.throw(msg, title=_("Incorrect Invoice")) + + receipt_documents.append(d.receipt_document) for item in self.get("items"): if not item.receipt_document: diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 6585e1c78cf..5f53be0869e 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -101,7 +101,8 @@ frappe.ui.form.on('Material Request', { } if (frm.doc.docstatus == 1 && frm.doc.status != 'Stopped') { - if (flt(frm.doc.per_ordered, 2) < 100) { + let precision = frappe.defaults.get_default("float_precision"); + if (flt(frm.doc.per_ordered, precision) < 100) { let add_create_pick_list_button = () => { frm.add_custom_button(__('Pick List'), () => frm.events.create_pick_list(frm), __('Create')); diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 335175f21d1..3ad9909ad07 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -189,7 +189,7 @@ class MaterialRequest(BuyingController): item_wh_list = [] for d in self.get("items"): if (not mr_item_rows or d.name in mr_item_rows) and [d.item_code, d.warehouse] not in item_wh_list \ - and frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1 and d.warehouse: + and d.warehouse and frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1 : item_wh_list.append([d.item_code, d.warehouse]) for item_code, warehouse in item_wh_list: diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index f1d7f8c8c9e..bb396e806f6 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -13,6 +13,7 @@ "section_break_6", "warehouse", "target_warehouse", + "conversion_factor", "column_break_9", "qty", "uom", @@ -209,13 +210,18 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "conversion_factor", + "fieldtype": "Float", + "label": "Conversion Factor" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-24 09:25:13.050151", + "modified": "2021-05-26 07:08:05.111385", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 5341f298531..4ab71bdf629 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -53,6 +53,7 @@ def update_packing_list_item(doc, packing_item_code, qty, main_item_row, descrip pi.parent_detail_docname = main_item_row.name pi.uom = item.stock_uom pi.qty = flt(qty) + pi.conversion_factor = main_item_row.conversion_factor if description and not pi.description: pi.description = description if not pi.warehouse and not doc.amended_from: diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index c01388dcd21..21467935370 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -184,4 +184,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 6ab68e292ae..e795742ea4d 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -17,6 +17,9 @@ from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note a # TODO: Prioritize SO or WO group warehouse class PickList(Document): + def validate(self): + self.validate_for_qty() + def before_save(self): self.set_item_locations() @@ -35,6 +38,7 @@ class PickList(Document): @frappe.whitelist() def set_item_locations(self, save=False): + self.validate_for_qty() items = self.aggregate_item_qty() self.item_location_map = frappe._dict() @@ -107,6 +111,11 @@ class PickList(Document): return item_map.values() + def validate_for_qty(self): + if self.purpose == "Material Transfer for Manufacture" \ + and (self.for_qty is None or self.for_qty == 0): + frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) + def validate_item_locations(pick_list): if not pick_list.locations: diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index c4da05a6d44..84566b8d8c7 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -37,6 +37,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', 'qty': 5, @@ -90,6 +91,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item Warehouse Group Wise Reorder', 'qty': 1000, @@ -135,6 +137,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Serialized Item', 'qty': 1000, @@ -264,6 +267,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', 'qty': 5, @@ -319,6 +323,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', 'qty': 1, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 32d349f3031..44fb736304f 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -514,8 +514,7 @@ "oldfieldname": "pr_raw_material_details", "oldfieldtype": "Table", "options": "Purchase Receipt Item Supplied", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "section_break0", @@ -762,6 +761,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment (Company Currency)", @@ -805,6 +805,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment", @@ -1147,7 +1148,7 @@ "idx": 261, "is_submittable": 1, "links": [], - "modified": "2020-12-26 20:49:39.106049", + "modified": "2021-05-25 00:15:12.239017", "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 83ba3244952..41800e37151 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -202,6 +202,7 @@ class PurchaseReceipt(BuyingController): self.make_gl_entries() self.repost_future_sle_and_gle() + self.set_consumed_qty_in_po() def check_next_docstatus(self): submit_rv = frappe.db.sql("""select t1.name @@ -233,6 +234,7 @@ class PurchaseReceipt(BuyingController): self.repost_future_sle_and_gle() self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry', 'Repost Item Valuation') self.delete_auto_created_batches() + self.set_consumed_qty_in_po() @frappe.whitelist() def get_current_stock(self): @@ -252,6 +254,8 @@ class PurchaseReceipt(BuyingController): return process_gl_map(gl_entries) def make_item_gl_entries(self, gl_entries, warehouse_account=None): + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import get_purchase_document_details + stock_rbnb = self.get_company_default("stock_received_but_not_billed") landed_cost_entries = get_item_account_wise_additional_cost(self.name) expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") @@ -260,6 +264,8 @@ class PurchaseReceipt(BuyingController): warehouse_with_no_account = [] stock_items = self.get_stock_items() + exchange_rate_map, net_rate_map = get_purchase_document_details(self) + for d in self.get("items"): if d.item_code in stock_items and flt(d.valuation_rate) and flt(d.qty): if warehouse_account.get(d.warehouse): @@ -302,6 +308,23 @@ class PurchaseReceipt(BuyingController): -1 * flt(d.base_net_amount, d.precision("base_net_amount")), 0.0, remarks, warehouse_account_name, debit_in_account_currency=-1 * credit_amount, account_currency=credit_currency, item=d) + # check if the exchange rate has changed + if d.get('purchase_invoice'): + if exchange_rate_map[d.purchase_invoice] and \ + self.conversion_rate != exchange_rate_map[d.purchase_invoice] and \ + d.net_rate == net_rate_map[d.purchase_invoice_item]: + + discrepancy_caused_by_exchange_rate_difference = (d.qty * d.net_rate) * \ + (exchange_rate_map[d.purchase_invoice] - self.conversion_rate) + + self.add_gl_entry(gl_entries, account, d.cost_center, 0.0, discrepancy_caused_by_exchange_rate_difference, + remarks, self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=credit_currency, item=d) + + self.add_gl_entry(gl_entries, self.get_company_default("exchange_gain_loss_account"), d.cost_center, discrepancy_caused_by_exchange_rate_difference, 0.0, + remarks, self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=credit_currency, item=d) + # Amount added through landed-cos-voucher if d.landed_cost_voucher_amount and landed_cost_entries: for account, amount in iteritems(landed_cost_entries[(d.item_code, d.name)]): @@ -384,6 +407,7 @@ class PurchaseReceipt(BuyingController): against_account = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0]) total_valuation_amount = sum(valuation_tax.values()) amount_including_divisional_loss = negative_expense_to_be_booked + stock_rbnb = self.get_company_default("stock_received_but_not_billed") i = 1 for tax in self.get("taxes"): if valuation_tax.get(tax.name): @@ -579,7 +603,6 @@ def update_billing_percentage(pr_doc, update_modified=True): @frappe.whitelist() def make_purchase_invoice(source_name, target_doc=None): - from frappe.model.mapper import get_mapped_doc from erpnext.accounts.party import get_payment_terms_template doc = frappe.get_doc('Purchase Receipt', source_name) @@ -599,11 +622,16 @@ def make_purchase_invoice(source_name, target_doc=None): def update_item(source_doc, target_doc, source_parent): target_doc.qty, returned_qty = get_pending_qty(source_doc) + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + target_doc.rejected_qty = 0 target_doc.stock_qty = flt(target_doc.qty) * flt(target_doc.conversion_factor, target_doc.precision("conversion_factor")) returned_qty_map[source_doc.name] = returned_qty def get_pending_qty(item_row): - pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0) + qty = item_row.qty + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + qty = item_row.received_qty + pending_qty = qty - invoiced_qty_map.get(item_row.name, 0) returned_qty = flt(returned_qty_map.get(item_row.name, 0)) if returned_qty: if returned_qty >= pending_qty: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 8d9b675bed3..dbba21fde1b 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -335,6 +335,10 @@ class TestPurchaseReceipt(unittest.TestCase): se2.cancel() se3.cancel() po.reload() + pr2.load_from_db() + pr2.cancel() + + po.load_from_db() po.cancel() def test_serial_no_supplier(self): @@ -417,11 +421,18 @@ class TestPurchaseReceipt(unittest.TestCase): self.assertEqual(return_pr_2.items[0].qty, -3) # Make PI against unreturned amount + buying_settings = frappe.get_single("Buying Settings") + buying_settings.bill_for_rejected_quantity_in_purchase_invoice = 0 + buying_settings.save() + pi = make_purchase_invoice(pr.name) pi.submit() self.assertEqual(pi.items[0].qty, 3) + buying_settings.bill_for_rejected_quantity_in_purchase_invoice = 1 + buying_settings.save() + pr.load_from_db() # PR should be completed on billing all unreturned amount self.assertEqual(pr.items[0].billed_amt, 150) @@ -763,8 +774,8 @@ class TestPurchaseReceipt(unittest.TestCase): pr1.items[0].purchase_receipt_item = pr.items[0].name pr1.submit() - pi = make_purchase_invoice(pr.name) - self.assertEqual(pi.items[0].qty, 3) + pi1 = make_purchase_invoice(pr.name) + self.assertEqual(pi1.items[0].qty, 3) pr1.cancel() pr.reload() @@ -1000,6 +1011,74 @@ class TestPurchaseReceipt(unittest.TestCase): self.assertEqual(pr.status, "To Bill") self.assertAlmostEqual(pr.per_billed, 50.0, places=2) + def test_service_item_purchase_with_perpetual_inventory(self): + company = '_Test Company with perpetual inventory' + service_item = '_Test Non Stock Item' + + before_test_value = frappe.db.get_value('Company', company, 'enable_perpetual_inventory_for_non_stock_items') + frappe.db.set_value('Company', company, 'enable_perpetual_inventory_for_non_stock_items', 1) + srbnb_account = 'Stock Received But Not Billed - TCP1' + frappe.db.set_value('Company', company, 'service_received_but_not_billed', srbnb_account) + + pr = make_purchase_receipt( + company=company, item=service_item, + warehouse='Finished Goods - TCP1', do_not_save=1 + ) + item_row_with_diff_rate = frappe.copy_doc(pr.items[0]) + item_row_with_diff_rate.rate = 100 + pr.append('items', item_row_with_diff_rate) + + pr.save() + pr.submit() + + item_one_gl_entry = frappe.db.get_all("GL Entry", { + 'voucher_type': pr.doctype, + 'voucher_no': pr.name, + 'account': srbnb_account, + 'voucher_detail_no': pr.items[0].name + }, pluck="name") + + item_two_gl_entry = frappe.db.get_all("GL Entry", { + 'voucher_type': pr.doctype, + 'voucher_no': pr.name, + 'account': srbnb_account, + 'voucher_detail_no': pr.items[1].name + }, pluck="name") + + # check if the entries are not merged into one + # seperate entries should be made since voucher_detail_no is different + self.assertEqual(len(item_one_gl_entry), 1) + self.assertEqual(len(item_two_gl_entry), 1) + + frappe.db.set_value('Company', company, 'enable_perpetual_inventory_for_non_stock_items', before_test_value) + + def test_purchase_receipt_with_exchange_rate_difference(self): + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice as create_purchase_invoice + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_purchase_receipt as create_purchase_receipt + + pi = create_purchase_invoice(company="_Test Company with perpetual inventory", + cost_center = "Main - TCP1", + warehouse = "Stores - TCP1", + expense_account ="_Test Account Cost for Goods Sold - TCP1", + currency = "USD", conversion_rate = 70) + + pr = create_purchase_receipt(pi.name) + pr.conversion_rate = 80 + pr.items[0].purchase_invoice = pi.name + pr.items[0].purchase_invoice_item = pi.items[0].name + + pr.save() + pr.submit() + + # Get exchnage gain and loss account + exchange_gain_loss_account = frappe.db.get_value('Company', pr.company, 'exchange_gain_loss_account') + + # fetching the latest GL Entry with exchange gain and loss account account + amount = frappe.db.get_value('GL Entry', {'account': exchange_gain_loss_account, 'voucher_no': pr.name}, 'credit') + discrepancy_caused_by_exchange_rate_diff = abs(pi.items[0].base_net_amount - pr.items[0].base_net_amount) + + self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + def get_sl_entries(voucher_type, voucher_no): return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index ea26caced04..0f50bcd6ea8 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -97,7 +97,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None): at_capacity, rules = get_ordered_putaway_rules(item_code, company, source_warehouse=source_warehouse) if not rules: - warehouse = source_warehouse or item.warehouse + warehouse = source_warehouse or item.get('warehouse') if at_capacity: # rules available, but no free space items_not_accomodated.append([item_code, pending_qty]) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index 56b046a92e1..f5d076a077a 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -1,29 +1,45 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt -from __future__ import unicode_literals -import frappe import unittest + +import frappe from frappe.utils import nowdate -from erpnext.stock.doctype.item.test_item import create_item + +from erpnext.controllers.stock_controller import ( + QualityInspectionNotSubmittedError, + QualityInspectionRejectedError, + QualityInspectionRequiredError, + make_quality_inspections, +) from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note -from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry -from erpnext.controllers.stock_controller import QualityInspectionRejectedError, QualityInspectionRequiredError, QualityInspectionNotSubmittedError +from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry # test_records = frappe.get_test_records('Quality Inspection') + class TestQualityInspection(unittest.TestCase): def setUp(self): create_item("_Test Item with QA") - frappe.db.set_value("Item", "_Test Item with QA", "inspection_required_before_delivery", 1) + frappe.db.set_value( + "Item", "_Test Item with QA", "inspection_required_before_delivery", 1 + ) def test_qa_for_delivery(self): - make_stock_entry(item_code="_Test Item with QA", target="_Test Warehouse - _TC", qty=1, basic_rate=100) + make_stock_entry( + item_code="_Test Item with QA", + target="_Test Warehouse - _TC", + qty=1, + basic_rate=100 + ) dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) self.assertRaises(QualityInspectionRequiredError, dn.submit) - qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, status="Rejected") + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, status="Rejected" + ) dn.reload() self.assertRaises(QualityInspectionRejectedError, dn.submit) @@ -38,7 +54,9 @@ class TestQualityInspection(unittest.TestCase): def test_qa_not_submit(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) - qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True) + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True + ) dn.items[0].quality_inspection = qa.name self.assertRaises(QualityInspectionNotSubmittedError, dn.submit) @@ -48,21 +66,28 @@ class TestQualityInspection(unittest.TestCase): def test_value_based_qi_readings(self): # Test QI based on acceptance values (Non formula) dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) - readings = [{ - "specification": "Iron Content", # numeric reading - "min_value": 0.1, - "max_value": 0.9, - "reading_1": "0.4" - }, - { - "specification": "Particle Inspection Needed", # non-numeric reading - "numeric": 0, - "value": "Yes", - "reading_value": "Yes" - }] + readings = [ + { + "specification": "Iron Content", # numeric reading + "min_value": 0.1, + "max_value": 0.9, + "reading_1": "0.4" + }, + { + "specification": "Particle Inspection Needed", # non-numeric reading + "numeric": 0, + "value": "Yes", + "reading_value": "Yes" + } + ] + + qa = create_quality_inspection( + reference_type="Delivery Note", + reference_name=dn.name, + readings=readings, + do_not_save=True + ) - qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, - readings=readings, do_not_save=True) qa.save() # status must be auto set as per formula @@ -74,36 +99,43 @@ class TestQualityInspection(unittest.TestCase): def test_formula_based_qi_readings(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) - readings = [{ - "specification": "Iron Content", # numeric reading - "formula_based_criteria": 1, - "acceptance_formula": "reading_1 > 0.35 and reading_1 < 0.50", - "reading_1": "0.4" - }, - { - "specification": "Calcium Content", # numeric reading - "formula_based_criteria": 1, - "acceptance_formula": "reading_1 > 0.20 and reading_1 < 0.50", - "reading_1": "0.7" - }, - { - "specification": "Mg Content", # numeric reading - "formula_based_criteria": 1, - "acceptance_formula": "mean < 0.9", - "reading_1": "0.5", - "reading_2": "0.7", - "reading_3": "random text" # check if random string input causes issues - }, - { - "specification": "Calcium Content", # non-numeric reading - "formula_based_criteria": 1, - "numeric": 0, - "acceptance_formula": "reading_value in ('Grade A', 'Grade B', 'Grade C')", - "reading_value": "Grade B" - }] + readings = [ + { + "specification": "Iron Content", # numeric reading + "formula_based_criteria": 1, + "acceptance_formula": "reading_1 > 0.35 and reading_1 < 0.50", + "reading_1": "0.4" + }, + { + "specification": "Calcium Content", # numeric reading + "formula_based_criteria": 1, + "acceptance_formula": "reading_1 > 0.20 and reading_1 < 0.50", + "reading_1": "0.7" + }, + { + "specification": "Mg Content", # numeric reading + "formula_based_criteria": 1, + "acceptance_formula": "mean < 0.9", + "reading_1": "0.5", + "reading_2": "0.7", + "reading_3": "random text" # check if random string input causes issues + }, + { + "specification": "Calcium Content", # non-numeric reading + "formula_based_criteria": 1, + "numeric": 0, + "acceptance_formula": "reading_value in ('Grade A', 'Grade B', 'Grade C')", + "reading_value": "Grade B" + } + ] + + qa = create_quality_inspection( + reference_type="Delivery Note", + reference_name=dn.name, + readings=readings, + do_not_save=True + ) - qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, - readings=readings, do_not_save=True) qa.save() # status must be auto set as per formula @@ -115,6 +147,60 @@ class TestQualityInspection(unittest.TestCase): qa.delete() dn.delete() + def test_make_quality_inspections_from_linked_document(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + for item in dn.items: + item.sample_size = item.qty + quality_inspections = make_quality_inspections(dn.doctype, dn.name, dn.items) + self.assertEqual(len(dn.items), len(quality_inspections)) + + # cleanup + for qi in quality_inspections: + frappe.delete_doc("Quality Inspection", qi) + dn.delete() + + def test_rejected_qi_validation(self): + """Test if rejected QI blocks Stock Entry as per Stock Settings.""" + se = make_stock_entry( + item_code="_Test Item with QA", + target="_Test Warehouse - _TC", + qty=1, + basic_rate=100, + inspection_required=True, + do_not_submit=True + ) + + readings = [ + { + "specification": "Iron Content", + "min_value": 0.1, + "max_value": 0.9, + "reading_1": "0.4" + } + ] + + qa = create_quality_inspection( + reference_type="Stock Entry", + reference_name=se.name, + readings=readings, + status="Rejected" + ) + + frappe.db.set_value("Stock Settings", None, "action_if_quality_inspection_is_rejected", "Stop") + se.reload() + self.assertRaises(QualityInspectionRejectedError, se.submit) # when blocked in Stock settings, block rejected QI + + frappe.db.set_value("Stock Settings", None, "action_if_quality_inspection_is_rejected", "Warn") + se.reload() + se.submit() # when allowed in Stock settings, allow rejected QI + + # teardown + qa.reload() + qa.cancel() + se.reload() + se.cancel() + frappe.db.set_value("Stock Settings", None, "action_if_quality_inspection_is_rejected", "Stop") + def create_quality_inspection(**args): args = frappe._dict(args) qa = frappe.new_doc("Quality Inspection") @@ -130,12 +216,11 @@ def create_quality_inspection(**args): if not args.readings: create_quality_inspection_parameter("Size") readings = {"specification": "Size", "min_value": 0, "max_value": 10} + if args.status == "Rejected": + readings["reading_1"] = "12" # status is auto set in child on save else: readings = args.readings - if args.status == "Rejected": - readings["reading_1"] = "12" # status is auto set in child on save - if isinstance(readings, list): for entry in readings: create_quality_inspection_parameter(entry["specification"]) @@ -150,10 +235,11 @@ def create_quality_inspection(**args): return qa + def create_quality_inspection_parameter(parameter): if not frappe.db.exists("Quality Inspection Parameter", parameter): frappe.get_doc({ "doctype": "Quality Inspection Parameter", "parameter": parameter, "description": parameter - }).insert() \ No newline at end of file + }).insert() 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 55f2ebb2241..5f31d9caf0d 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -133,6 +133,6 @@ def repost_entries(): def get_repost_item_valuation_entries(): return frappe.db.sql(""" SELECT name from `tabRepost Item Valuation` - WHERE status != 'Completed' and creation <= %s and docstatus = 1 + WHERE status in ('Queued', 'In Progress') and creation <= %s and docstatus = 1 ORDER BY timestamp(posting_date, posting_time) asc, creation asc """, now(), as_dict=1) diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json index 3acf3a9316c..a3d44af4945 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.json +++ b/erpnext/stock/doctype/serial_no/serial_no.json @@ -57,7 +57,8 @@ "more_info", "serial_no_details", "company", - "status" + "status", + "work_order" ], "fields": [ { @@ -422,12 +423,18 @@ "label": "Status", "options": "\nActive\nInactive\nDelivered\nExpired", "read_only": 1 + }, + { + "fieldname": "work_order", + "fieldtype": "Link", + "label": "Work Order", + "options": "Work Order" } ], "icon": "fa fa-barcode", "idx": 1, "links": [], - "modified": "2020-07-20 20:50:16.660433", + "modified": "2021-01-08 14:31:15.375996", "modified_by": "Administrator", "module": "Stock", "name": "Serial No", diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index b236f6a9990..bad7b608acf 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -473,16 +473,13 @@ def get_serial_nos(serial_no): if s.strip()] def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False): - serial_no_doc.update({ - "item_code": args.get("item_code"), - "company": args.get("company"), - "batch_no": args.get("batch_no"), - "via_stock_ledger": args.get("via_stock_ledger") or True, - "supplier": args.get("supplier"), - "location": args.get("location"), - "warehouse": (args.get("warehouse") - if args.get("actual_qty", 0) > 0 else None) - }) + for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]: + if args.get(field): + serial_no_doc.set(field, args.get(field)) + + serial_no_doc.via_stock_ledger = args.get("via_stock_ledger") or True + serial_no_doc.warehouse = (args.get("warehouse") + if args.get("actual_qty", 0) > 0 else None) if is_new: serial_no_doc.serial_no = serial_no diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 7952adb0328..908020d02ba 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -115,6 +115,14 @@ frappe.ui.form.on('Stock Entry', { return; } + if (!frm.is_new() && frm.doc.docstatus === 0) { + frm.add_custom_button(__("Quality Inspection(s)"), () => { + let transaction_controller = new erpnext.TransactionController({ frm: frm }); + transaction_controller.make_quality_inspection(); + }, __("Create")); + frm.page.set_inner_btn_group_as_primary(__('Create')); + } + let quality_inspection_field = frm.get_docfield("items", "quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function(row) { if (frm.is_new()) return; @@ -155,7 +163,7 @@ frappe.ui.form.on('Stock Entry', { refresh: function(frm) { if(!frm.doc.docstatus) { frm.trigger('validate_purpose_consumption'); - frm.add_custom_button(__('Create Material Request'), function() { + frm.add_custom_button(__('Material Request'), function() { frappe.model.with_doctype('Material Request', function() { var mr = frappe.model.get_new_doc('Material Request'); var items = frm.get_field('items').grid.get_selected_children(); @@ -178,7 +186,7 @@ frappe.ui.form.on('Stock Entry', { }); frappe.set_route('Form', 'Material Request', mr.name); }); - }); + }, __("Create")); } if(frm.doc.items) { @@ -1071,6 +1079,10 @@ erpnext.stock.select_batch_and_serial_no = (frm, item) => { } function attach_bom_items(bom_no) { + if (!bom_no) { + return + } + if (check_should_not_attach_bom_items(bom_no)) return frappe.db.get_doc("BOM",bom_no).then(bom => { const {name, items} = bom diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 7f945910051..523d332b8f4 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -59,10 +59,6 @@ "supplier_name", "supplier_address", "address_display", - "column_break_39", - "customer", - "customer_name", - "customer_address", "accounting_dimensions_section", "project", "dimension_col_break", @@ -78,7 +74,8 @@ "total_amount", "job_card", "amended_from", - "credit_note" + "credit_note", + "is_return" ], "fields": [ { @@ -435,13 +432,13 @@ }, { "collapsible": 1, - "depends_on": "eval: in_list([\"Sales Return\", \"Purchase Return\", \"Send to Subcontractor\"], doc.purpose)", + "depends_on": "eval:doc.purpose === \"Send to Subcontractor\"", "fieldname": "contact_section", "fieldtype": "Section Break", - "label": "Customer or Supplier Details" + "label": "Supplier Details" }, { - "depends_on": "eval:doc.purpose==\"Purchase Return\" || doc.purpose==\"Send to Subcontractor\"", + "depends_on": "eval:doc.purpose === \"Send to Subcontractor\"", "fieldname": "supplier", "fieldtype": "Link", "label": "Supplier", @@ -453,7 +450,7 @@ }, { "bold": 1, - "depends_on": "eval:doc.purpose==\"Purchase Return\" || doc.purpose==\"Send to Subcontractor\"", + "depends_on": "eval:doc.purpose === \"Send to Subcontractor\"", "fieldname": "supplier_name", "fieldtype": "Data", "label": "Supplier Name", @@ -463,7 +460,7 @@ "read_only": 1 }, { - "depends_on": "eval:doc.purpose==\"Purchase Return\" || doc.purpose==\"Send to Subcontractor\"", + "depends_on": "eval:doc.purpose === \"Send to Subcontractor\"", "fieldname": "supplier_address", "fieldtype": "Link", "label": "Supplier Address", @@ -477,41 +474,6 @@ "fieldtype": "Small Text", "label": "Address" }, - { - "fieldname": "column_break_39", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval:doc.purpose==\"Sales Return\"", - "fieldname": "customer", - "fieldtype": "Link", - "label": "Customer", - "no_copy": 1, - "oldfieldname": "customer", - "oldfieldtype": "Link", - "options": "Customer", - "print_hide": 1 - }, - { - "bold": 1, - "depends_on": "eval:doc.purpose==\"Sales Return\"", - "fieldname": "customer_name", - "fieldtype": "Data", - "label": "Customer Name", - "no_copy": 1, - "oldfieldname": "customer_name", - "oldfieldtype": "Data", - "read_only": 1 - }, - { - "depends_on": "eval:doc.purpose==\"Sales Return\"", - "fieldname": "customer_address", - "fieldtype": "Small Text", - "label": "Customer Address", - "no_copy": 1, - "oldfieldname": "customer_address", - "oldfieldtype": "Small Text" - }, { "collapsible": 1, "fieldname": "printing_settings", @@ -650,6 +612,16 @@ "fieldname": "apply_putaway_rule", "fieldtype": "Check", "label": "Apply Putaway Rule" + }, + { + "default": "0", + "fieldname": "is_return", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Return", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "icon": "fa fa-file-text", @@ -657,7 +629,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-05-21 11:29:11.917161", + "modified": "2021-05-26 17:07:58.015737", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 560ceaa917b..fcb6f0f4c2f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -72,7 +72,7 @@ class StockEntry(StockController): self.validate_with_material_request() self.validate_batch() self.validate_inspection() - self.validate_fg_completed_qty() + # self.validate_fg_completed_qty() self.validate_difference_account() self.set_job_card_data() self.set_purpose_for_stock_entry() @@ -97,8 +97,7 @@ class StockEntry(StockController): update_serial_nos_after_submit(self, "items") self.update_work_order() self.validate_purchase_order() - if self.purchase_order and self.purpose == "Send to Subcontractor": - self.update_purchase_order_supplied_items() + self.update_purchase_order_supplied_items() self.make_gl_entries() @@ -117,9 +116,7 @@ class StockEntry(StockController): self.set_material_request_transfer_status('Completed') def on_cancel(self): - - if self.purchase_order and self.purpose == "Send to Subcontractor": - self.update_purchase_order_supplied_items() + self.update_purchase_order_supplied_items() if self.work_order and self.purpose == "Material Consumption for Manufacture": self.validate_work_order_status() @@ -501,6 +498,7 @@ class StockEntry(StockController): d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) if not d.t_warehouse: outgoing_items_cost += flt(d.basic_amount) + return outgoing_items_cost def get_args_for_incoming_rate(self, item): @@ -531,7 +529,7 @@ class StockEntry(StockController): scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item]) # Get raw materials cost from BOM if multiple material consumption entries - if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): + if frappe.db.get_single_value("Manufacturing Settings", "material_consumption", cache=True): bom_items = self.get_bom_raw_materials(finished_item_qty) outgoing_items_cost = sum([flt(row.qty)*flt(row.rate) for row in bom_items.values()]) @@ -721,6 +719,10 @@ class StockEntry(StockController): frappe.throw(_("Multiple items cannot be marked as finished item")) if self.purpose == "Manufacture": + if not finished_items: + frappe.throw(_('Finished Good has not set in the stock entry {0}') + .format(self.name)) + allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")) @@ -857,6 +859,7 @@ class StockEntry(StockController): pro_doc.run_method("update_work_order_qty") if self.purpose == "Manufacture": pro_doc.run_method("update_planned_qty") + pro_doc.update_batch_produced_qty(self) if not pro_doc.operations: pro_doc.set_actual_dates() @@ -1008,10 +1011,12 @@ class StockEntry(StockController): if self.purchase_order and self.purpose == "Send to Subcontractor": #Get PO Supplied Items Details item_wh = frappe._dict(frappe.db.sql(""" - select rm_item_code, reserve_warehouse - from `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup - where po.name = poitemsup.parent - and po.name = %s""",self.purchase_order)) + SELECT + rm_item_code, reserve_warehouse + FROM + `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup + WHERE + po.name = poitemsup.parent and po.name = %s """,self.purchase_order)) for item in itervalues(item_dict): if self.pro_doc and cint(self.pro_doc.from_wip_warehouse): @@ -1077,18 +1082,64 @@ class StockEntry(StockController): # in case of BOM to_warehouse = item.get("default_warehouse") + args = { + "to_warehouse": to_warehouse, + "from_warehouse": "", + "qty": self.fg_completed_qty, + "item_name": item.item_name, + "description": item.description, + "stock_uom": item.stock_uom, + "expense_account": item.get("expense_account"), + "cost_center": item.get("buying_cost_center"), + "is_finished_item": 1 + } + + if self.work_order and self.pro_doc.has_batch_no and cint(frappe.db.get_single_value('Manufacturing Settings', + 'make_serial_no_batch_from_work_order', cache=True)): + self.set_batchwise_finished_goods(args, item) + else: + self.add_finished_goods(args, item) + + def set_batchwise_finished_goods(self, args, item): + filters = { + "reference_name": self.pro_doc.name, + "reference_doctype": self.pro_doc.doctype, + "qty_to_produce": (">", 0) + } + + fields = ["qty_to_produce as qty", "produced_qty", "name"] + + data = frappe.get_all("Batch", filters = filters, fields = fields, order_by="creation asc") + + if not data: + self.add_finished_goods(args, item) + else: + self.add_batchwise_finished_good(data, args, item) + + def add_batchwise_finished_good(self, data, args, item): + qty = flt(self.fg_completed_qty) + + for row in data: + batch_qty = flt(row.qty) - flt(row.produced_qty) + if not batch_qty: + continue + + if qty <=0: + break + + fg_qty = batch_qty + if batch_qty >= qty: + fg_qty = qty + + qty -= batch_qty + args["qty"] = fg_qty + args["batch_no"] = row.name + + self.add_finished_goods(args, item) + + def add_finished_goods(self, args, item): self.add_to_stock_entry_detail({ - item.name: { - "to_warehouse": to_warehouse, - "from_warehouse": "", - "qty": self.fg_completed_qty, - "item_name": item.item_name, - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": item.get("expense_account"), - "cost_center": item.get("buying_cost_center"), - "is_finished_item": 1 - } + item.name: args }, bom_no = self.bom_no) def get_bom_raw_materials(self, qty): @@ -1294,7 +1345,8 @@ class StockEntry(StockController): item_dict[item]["qty"] = 0 # delete items with 0 qty - for item in item_dict.keys(): + list_of_items = item_dict.keys() + for item in list_of_items: if not item_dict[item]["qty"]: del item_dict[item] @@ -1347,7 +1399,7 @@ class StockEntry(StockController): se_child.is_scrap_item = item_dict[d].get("is_scrap_item", 0) for field in ["idx", "po_detail", "original_item", - "expense_account", "description", "item_name"]: + "expense_account", "description", "item_name", "serial_no", "batch_no"]: if item_dict[d].get(field): se_child.set(field, item_dict[d].get(field)) @@ -1400,33 +1452,26 @@ class StockEntry(StockController): .format(item.batch_no, item.item_code)) def update_purchase_order_supplied_items(self): - #Get PO Supplied Items Details - item_wh = frappe._dict(frappe.db.sql(""" - select rm_item_code, reserve_warehouse - from `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup - where po.name = poitemsup.parent - and po.name = %s""", self.purchase_order)) + if (self.purchase_order and + (self.purpose in ['Send to Subcontractor', 'Material Transfer'] or self.is_return)): - #Update Supplied Qty in PO Supplied Items + #Get PO Supplied Items Details + item_wh = frappe._dict(frappe.db.sql(""" + select rm_item_code, reserve_warehouse + from `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup + where po.name = poitemsup.parent + and po.name = %s""", self.purchase_order)) - frappe.db.sql("""UPDATE `tabPurchase Order Item Supplied` pos - SET - pos.supplied_qty = IFNULL((SELECT ifnull(sum(transfer_qty), 0) - FROM - `tabStock Entry Detail` sed, `tabStock Entry` se - WHERE - pos.name = sed.po_detail AND pos.rm_item_code = sed.item_code - AND pos.parent = se.purchase_order AND sed.docstatus = 1 - AND se.name = sed.parent and se.purchase_order = %(po)s - ), 0) - WHERE pos.docstatus = 1 and pos.parent = %(po)s""", {"po": self.purchase_order}) + supplied_items = get_supplied_items(self.purchase_order) + for name, item in supplied_items.items(): + frappe.db.set_value('Purchase Order Item Supplied', name, item) - #Update reserved sub contracted quantity in bin based on Supplied Item Details and - for d in self.get("items"): - item_code = d.get('original_item') or d.get('item_code') - reserve_warehouse = item_wh.get(item_code) - stock_bin = get_bin(item_code, reserve_warehouse) - stock_bin.update_reserved_qty_for_sub_contracting() + #Update reserved sub contracted quantity in bin based on Supplied Item Details and + for d in self.get("items"): + item_code = d.get('original_item') or d.get('item_code') + reserve_warehouse = item_wh.get(item_code) + stock_bin = get_bin(item_code, reserve_warehouse) + stock_bin.update_reserved_qty_for_sub_contracting() def update_so_in_serial_number(self): so_name, item_code = frappe.db.get_value("Work Order", self.work_order, ["sales_order", "production_item"]) @@ -1480,7 +1525,7 @@ class StockEntry(StockController): cond += """ WHEN (parent = %s and name = %s) THEN %s """ %(frappe.db.escape(data[0]), frappe.db.escape(data[1]), transferred_qty) - if cond and stock_entries_child_list: + if stock_entries_child_list: frappe.db.sql(""" UPDATE `tabStock Entry Detail` SET transferred_qty = CASE {cond} END @@ -1531,6 +1576,36 @@ class StockEntry(StockController): material_requests.append(material_request) frappe.db.set_value('Material Request', material_request, 'transfer_status', status) + def set_serial_no_batch_for_finished_good(self): + args = {} + if self.pro_doc.serial_no: + self.get_serial_nos_for_fg(args) + + for row in self.items: + if row.is_finished_item and row.item_code == self.pro_doc.production_item: + if args.get("serial_no"): + row.serial_no = '\n'.join(args["serial_no"][0: cint(row.qty)]) + + def get_serial_nos_for_fg(self, args): + fields = ["`tabStock Entry`.`name`", "`tabStock Entry Detail`.`qty`", + "`tabStock Entry Detail`.`serial_no`", "`tabStock Entry Detail`.`batch_no`"] + + filters = [["Stock Entry","work_order","=",self.work_order], ["Stock Entry","purpose","=","Manufacture"], + ["Stock Entry","docstatus","=",1], ["Stock Entry Detail","item_code","=",self.pro_doc.production_item]] + + stock_entries = frappe.get_all("Stock Entry", fields=fields, filters=filters) + + if self.pro_doc.serial_no: + args["serial_no"] = self.get_available_serial_nos(stock_entries) + + def get_available_serial_nos(self, stock_entries): + used_serial_nos = [] + for row in stock_entries: + if row.serial_no: + used_serial_nos.extend(get_serial_nos(row.serial_no)) + + return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos))) + @frappe.whitelist() def move_sample_to_retention_warehouse(company, items): if isinstance(items, string_types): @@ -1642,6 +1717,10 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): if bom.quantity: operating_cost_per_unit = flt(bom.operating_cost) / flt(bom.quantity) + if work_order and work_order.produced_qty and cint(frappe.db.get_single_value('Manufacturing Settings', + 'add_corrective_operation_cost_in_finished_good_valuation')): + operating_cost_per_unit += flt(work_order.corrective_operation_cost) / flt(work_order.produced_qty) + return operating_cost_per_unit def get_used_alternative_items(purchase_order=None, work_order=None): @@ -1751,3 +1830,30 @@ def validate_sample_quantity(item_code, sample_quantity, qty, batch_no = None): format(max_retain_qty, batch_no, item_code), alert=True) sample_quantity = qty_diff return sample_quantity + +def get_supplied_items(purchase_order): + fields = ['`tabStock Entry Detail`.`transfer_qty`', '`tabStock Entry`.`is_return`', + '`tabStock Entry Detail`.`po_detail`', '`tabStock Entry Detail`.`item_code`'] + + filters = [['Stock Entry', 'docstatus', '=', 1], ['Stock Entry', 'purchase_order', '=', purchase_order]] + + supplied_item_details = {} + for row in frappe.get_all('Stock Entry', fields = fields, filters = filters): + if not row.po_detail: + continue + + key = row.po_detail + if key not in supplied_item_details: + supplied_item_details.setdefault(key, + frappe._dict({'supplied_qty': 0, 'returned_qty':0, 'total_supplied_qty':0})) + + supplied_item = supplied_item_details[key] + + if row.is_return: + supplied_item.returned_qty += row.transfer_qty + else: + supplied_item.supplied_qty += row.transfer_qty + + supplied_item.total_supplied_qty = flt(supplied_item.supplied_qty) - flt(supplied_item.returned_qty) + + return supplied_item_details \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index b12a8547fea..563fcb03973 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -45,6 +45,8 @@ def make_stock_entry(**args): s.posting_date = args.posting_date if args.posting_time: s.posting_time = args.posting_time + if args.inspection_required: + s.inspection_required = args.inspection_required # map names if args.from_warehouse: diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 864ff488b22..22f412a2989 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -18,6 +18,7 @@ "col_break2", "is_finished_item", "is_scrap_item", + "quality_inspection", "subcontracted_item", "section_break_8", "description", @@ -69,7 +70,6 @@ "putaway_rule", "column_break_51", "reference_purchase_receipt", - "quality_inspection", "job_card_item" ], "fields": [ @@ -307,6 +307,7 @@ "fieldname": "quality_inspection", "fieldtype": "Link", "label": "Quality Inspection", + "no_copy": 1, "options": "Quality Inspection" }, { @@ -548,7 +549,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-11 13:47:50.158754", + "modified": "2021-06-21 16:03:18.834880", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index b0e7440e6cc..93482e8beab 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import flt, getdate, add_days, formatdate, get_datetime, date_diff +from frappe.utils import flt, getdate, add_days, formatdate, get_datetime, cint from frappe.model.document import Document from datetime import date from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock @@ -89,17 +89,16 @@ class StockLedgerEntry(Document): if item_det.is_stock_item != 1: frappe.throw(_("Item {0} must be a stock Item").format(self.item_code)) - # check if batch number is required - if self.voucher_type != 'Stock Reconciliation': - if item_det.has_batch_no == 1: - batch_item = self.item_code if self.item_code == item_det.item_name else self.item_code + ":" + item_det.item_name - if not self.batch_no: - frappe.throw(_("Batch number is mandatory for Item {0}").format(batch_item)) - elif not frappe.db.get_value("Batch",{"item": self.item_code, "name": self.batch_no}): - frappe.throw(_("{0} is not a valid Batch Number for Item {1}").format(self.batch_no, batch_item)) + # check if batch number is valid + if item_det.has_batch_no == 1: + batch_item = self.item_code if self.item_code == item_det.item_name else self.item_code + ":" + item_det.item_name + if not self.batch_no: + frappe.throw(_("Batch number is mandatory for Item {0}").format(batch_item)) + elif not frappe.db.get_value("Batch",{"item": self.item_code, "name": self.batch_no}): + frappe.throw(_("{0} is not a valid Batch Number for Item {1}").format(self.batch_no, batch_item)) - elif item_det.has_batch_no == 0 and self.batch_no and self.is_cancelled == 0: - frappe.throw(_("The Item {0} cannot have Batch").format(self.item_code)) + elif item_det.has_batch_no == 0 and self.batch_no and self.is_cancelled == 0: + frappe.throw(_("The Item {0} cannot have Batch").format(self.item_code)) if item_det.has_variants: frappe.throw(_("Stock cannot exist for Item {0} since has variants").format(self.item_code), @@ -108,17 +107,18 @@ class StockLedgerEntry(Document): self.stock_uom = item_det.stock_uom def check_stock_frozen_date(self): - stock_frozen_upto = frappe.db.get_value('Stock Settings', None, 'stock_frozen_upto') or '' - if stock_frozen_upto: - stock_auth_role = frappe.db.get_value('Stock Settings', None,'stock_auth_role') - if getdate(self.posting_date) <= getdate(stock_frozen_upto) and not stock_auth_role in frappe.get_roles(): - frappe.throw(_("Stock transactions before {0} are frozen").format(formatdate(stock_frozen_upto)), StockFreezeError) + stock_settings = frappe.get_doc('Stock Settings', 'Stock Settings') - stock_frozen_upto_days = int(frappe.db.get_value('Stock Settings', None, 'stock_frozen_upto_days') or 0) + if stock_settings.stock_frozen_upto: + if (getdate(self.posting_date) <= getdate(stock_settings.stock_frozen_upto) + and stock_settings.stock_auth_role not in frappe.get_roles()): + frappe.throw(_("Stock transactions before {0} are frozen") + .format(formatdate(stock_settings.stock_frozen_upto)), StockFreezeError) + + stock_frozen_upto_days = cint(stock_settings.stock_frozen_upto_days) if stock_frozen_upto_days: - stock_auth_role = frappe.db.get_value('Stock Settings', None,'stock_auth_role') older_than_x_days_ago = (add_days(getdate(self.posting_date), stock_frozen_upto_days) <= date.today()) - if older_than_x_days_ago and not stock_auth_role in frappe.get_roles(): + if older_than_x_days_ago and stock_settings.stock_auth_role not in frappe.get_roles(): frappe.throw(_("Not allowed to update stock transactions older than {0}").format(stock_frozen_upto_days), StockFreezeError) def scrub_posting_time(self): @@ -177,3 +177,4 @@ def on_doctype_update(): frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"]) frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"]) + frappe.db.add_index("Stock Ledger Entry", ["voucher_detail_no"]) 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 ba31ad7b06b..af2ada8c9a4 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 @@ -54,7 +54,7 @@ class TestStockLedgerEntry(unittest.TestCase): ) # _Test Item for Reposting transferred from Stores to FG warehouse on 30-04-2020 - make_stock_entry( + se = make_stock_entry( item_code="_Test Item for Reposting", source="Stores - _TC", target="Finished Goods - _TC", @@ -64,29 +64,29 @@ class TestStockLedgerEntry(unittest.TestCase): posting_date='2020-04-30', posting_time='14:00' ) - target_wh_sle = get_previous_sle({ + target_wh_sle = frappe.db.get_value('Stock Ledger Entry', { "item_code": "_Test Item for Reposting", "warehouse": "Finished Goods - _TC", - "posting_date": '2020-04-30', - "posting_time": '14:00' - }) + "voucher_type": "Stock Entry", + "voucher_no": se.name + }, ["valuation_rate"], as_dict=1) self.assertEqual(target_wh_sle.get("valuation_rate"), 150) # Repack entry on 5-5-2020 repack = create_repack_entry(company=company, posting_date='2020-05-05', posting_time='14:00') - finished_item_sle = get_previous_sle({ + finished_item_sle = frappe.db.get_value('Stock Ledger Entry', { "item_code": "_Test Finished Item for Reposting", "warehouse": "Finished Goods - _TC", - "posting_date": '2020-05-05', - "posting_time": '14:00' - }) + "voucher_type": "Stock Entry", + "voucher_no": repack.name + }, ["incoming_rate", "valuation_rate"], as_dict=1) self.assertEqual(finished_item_sle.get("incoming_rate"), 540) self.assertEqual(finished_item_sle.get("valuation_rate"), 540) # Reconciliation for _Test Item for Reposting at Stores on 12-04-2020: Qty = 50, Rate = 150 - create_stock_reconciliation( + sr = create_stock_reconciliation( item_code="_Test Item for Reposting", warehouse="Stores - _TC", qty=50, @@ -109,12 +109,12 @@ class TestStockLedgerEntry(unittest.TestCase): self.assertEqual(target_wh_sle.get("valuation_rate"), 175) # Check valuation rate of repacked item after back-dated entry at Stores - finished_item_sle = get_previous_sle({ + finished_item_sle = frappe.db.get_value('Stock Ledger Entry', { "item_code": "_Test Finished Item for Reposting", "warehouse": "Finished Goods - _TC", - "posting_date": '2020-05-05', - "posting_time": '14:00' - }) + "voucher_type": "Stock Entry", + "voucher_no": repack.name + }, ["incoming_rate", "valuation_rate"], as_dict=1) self.assertEqual(finished_item_sle.get("incoming_rate"), 790) self.assertEqual(finished_item_sle.get("valuation_rate"), 790) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 3badc7ee60c..45409544894 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -17,6 +17,14 @@ frappe.ui.form.on("Stock Reconciliation", { } } }); + frm.set_query("batch_no", "items", function(doc, cdt, cdn) { + var item = locals[cdt][cdn]; + return { + filters: { + 'item': item.item_code + } + }; + }); if (frm.doc.company) { erpnext.queries.setup_queries(frm, "Warehouse", function() { @@ -48,37 +56,54 @@ frappe.ui.form.on("Stock Reconciliation", { }, get_items: function(frm) { - frappe.prompt({label:"Warehouse", fieldname: "warehouse", fieldtype:"Link", options:"Warehouse", reqd: 1, + let fields = [{ + label: 'Warehouse', fieldname: 'warehouse', fieldtype: 'Link', options: 'Warehouse', reqd: 1, "get_query": function() { return { "filters": { "company": frm.doc.company, } - } - }}, - function(data) { - frappe.call({ - method:"erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_items", - args: { - warehouse: data.warehouse, - posting_date: frm.doc.posting_date, - posting_time: frm.doc.posting_time, - company:frm.doc.company - }, - callback: function(r) { - var items = []; - frm.clear_table("items"); - for(var i=0; i< r.message.length; i++) { - var d = frm.add_child("items"); - $.extend(d, r.message[i]); - if(!d.qty) d.qty = null; - if(!d.valuation_rate) d.valuation_rate = null; - } - frm.refresh_field("items"); - } - }); + }; } - , __("Get Items"), __("Update")); + }, { + label: "Item Code", fieldname: "item_code", fieldtype: "Link", options: "Item", + "get_query": function() { + return { + "filters": { + "disabled": 0, + } + }; + } + }]; + + frappe.prompt(fields, function(data) { + frappe.call({ + method: "erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_items", + args: { + warehouse: data.warehouse, + posting_date: frm.doc.posting_date, + posting_time: frm.doc.posting_time, + company: frm.doc.company, + item_code: data.item_code + }, + callback: function(r) { + frm.clear_table("items"); + for (var i=0; i 100: + msgprint(_("The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Submitted stage")) + self.queue_action('cancel', timeout=2000) + else: + self._cancel() + @frappe.whitelist() -def get_items(warehouse, posting_date, posting_time, company): +def get_items(warehouse, posting_date, posting_time, company, item_code=None): + items = [frappe._dict({ + 'item_code': item_code, + 'warehouse': warehouse + })] + + if not item_code: + items = get_items_for_stock_reco(warehouse, company) + + res = [] + itemwise_batch_data = get_itemwise_batch(warehouse, posting_date, company, item_code) + + for d in items: + if d.item_code in itemwise_batch_data: + stock_bal = get_stock_balance(d.item_code, d.warehouse, + posting_date, posting_time, with_valuation_rate=True) + + for row in itemwise_batch_data.get(d.item_code): + args = get_item_data(row, row.qty, stock_bal[1]) + res.append(args) + else: + stock_bal = get_stock_balance(d.item_code, d.warehouse, posting_date, posting_time, + with_valuation_rate=True , with_serial_no=cint(d.has_serial_no)) + + args = get_item_data(d, stock_bal[0], stock_bal[1], + stock_bal[2] if cint(d.has_serial_no) else '') + + res.append(args) + + return res + +def get_items_for_stock_reco(warehouse, company): lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"]) items = frappe.db.sql(""" - select i.name, i.item_name, bin.warehouse, i.has_serial_no + select i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no from tabBin bin, tabItem i - where i.name=bin.item_code and i.disabled=0 and i.is_stock_item = 1 - and i.has_variants = 0 and i.has_batch_no = 0 - and exists(select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=bin.warehouse) - """, (lft, rgt)) + where i.name=bin.item_code and IFNULL(i.disabled, 0) = 0 and i.is_stock_item = 1 + and i.has_variants = 0 and exists( + select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=bin.warehouse + ) + """, (lft, rgt), as_dict=1) items += frappe.db.sql(""" - select i.name, i.item_name, id.default_warehouse, i.has_serial_no + select i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no from tabItem i, `tabItem Default` id where i.name = id.parent and exists(select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=id.default_warehouse) - and i.is_stock_item = 1 and i.has_batch_no = 0 - and i.has_variants = 0 and i.disabled = 0 and id.company=%s + and i.is_stock_item = 1 and i.has_variants = 0 and IFNULL(i.disabled, 0) = 0 and id.company=%s group by i.name - """, (lft, rgt, company)) + """, (lft, rgt, company), as_dict=1) - res = [] - for d in set(items): - stock_bal = get_stock_balance(d[0], d[2], posting_date, posting_time, - with_valuation_rate=True , with_serial_no=cint(d[3])) + return items - if frappe.db.get_value("Item", d[0], "disabled") == 0: - res.append({ - "item_code": d[0], - "warehouse": d[2], - "qty": stock_bal[0], - "item_name": d[1], - "valuation_rate": stock_bal[1], - "current_qty": stock_bal[0], - "current_valuation_rate": stock_bal[1], - "current_serial_no": stock_bal[2] if cint(d[3]) else '', - "serial_no": stock_bal[2] if cint(d[3]) else '' - }) +def get_item_data(row, qty, valuation_rate, serial_no=None): + return { + 'item_code': row.item_code, + 'warehouse': row.warehouse, + 'qty': qty, + 'item_name': row.item_name, + 'valuation_rate': valuation_rate, + 'current_qty': qty, + 'current_valuation_rate': valuation_rate, + 'current_serial_no': serial_no, + 'serial_no': serial_no, + 'batch_no': row.get('batch_no') + } - return res +def get_itemwise_batch(warehouse, posting_date, company, item_code=None): + from erpnext.stock.report.batch_wise_balance_history.batch_wise_balance_history import execute + itemwise_batch_data = {} + + filters = frappe._dict({ + 'warehouse': warehouse, + 'from_date': posting_date, + 'to_date': posting_date, + 'company': company + }) + + if item_code: + filters.item_code = item_code + + columns, data = execute(filters) + + for row in data: + itemwise_batch_data.setdefault(row[0], []).append(frappe._dict({ + 'item_code': row[0], + 'warehouse': warehouse, + 'qty': row[8], + 'item_name': row[1], + 'batch_no': row[4] + })) + + return itemwise_batch_data @frappe.whitelist() def get_stock_balance_for(item_code, warehouse, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 36380b838b1..c192582531a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe, unittest -from frappe.utils import flt, nowdate, nowtime +from frappe.utils import flt, nowdate, nowtime, random_string, add_days from erpnext.accounts.utils import get_stock_and_account_balance from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items @@ -14,6 +14,8 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + class TestStockReconciliation(unittest.TestCase): @classmethod @@ -150,6 +152,42 @@ class TestStockReconciliation(unittest.TestCase): stock_doc = frappe.get_doc("Stock Reconciliation", d) stock_doc.cancel() + + def test_stock_reco_for_merge_serialized_item(self): + to_delete_records = [] + + # Add new serial nos + serial_item_code = "Stock-Reco-Serial-Item-2" + serial_warehouse = "_Test Warehouse for Stock Reco1 - _TC" + + sr = create_stock_reconciliation(item_code=serial_item_code, serial_no=random_string(6), + warehouse = serial_warehouse, qty=1, rate=100, do_not_submit=True, purpose='Opening Stock') + + for i in range(3): + sr.append('items', { + 'item_code': serial_item_code, + 'warehouse': serial_warehouse, + 'qty': 1, + 'valuation_rate': 100, + 'serial_no': random_string(6) + }) + + sr.save() + sr.submit() + + sle_entries = frappe.get_all('Stock Ledger Entry', filters= {'voucher_no': sr.name}, + fields = ['name', 'incoming_rate']) + + self.assertEqual(len(sle_entries), 1) + self.assertEqual(sle_entries[0].incoming_rate, 100) + + to_delete_records.append(sr.name) + to_delete_records.reverse() + + for d in to_delete_records: + stock_doc = frappe.get_doc("Stock Reconciliation", d) + stock_doc.cancel() + def test_stock_reco_for_batch_item(self): to_delete_records = [] to_delete_serial_nos = [] @@ -204,6 +242,137 @@ class TestStockReconciliation(unittest.TestCase): self.assertEqual(sr.get("items")[0].valuation_rate, 0) self.assertEqual(sr.get("items")[0].amount, 0) + def test_backdated_stock_reco_qty_reposting(self): + """ + Test if a backdated stock reco recalculates future qty until next reco. + ------------------------------------------- + Var | Doc | Qty | Balance + ------------------------------------------- + SR5 | Reco | 0 | 8 (posting date: today-4) [backdated] + PR1 | PR | 10 | 18 (posting date: today-3) + PR2 | PR | 1 | 19 (posting date: today-2) + SR4 | Reco | 0 | 6 (posting date: today-1) [backdated] + PR3 | PR | 1 | 7 (posting date: today) # can't post future PR + """ + item_code = "Backdated-Reco-Item" + warehouse = "_Test Warehouse - _TC" + create_item(item_code) + + pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100, + posting_date=add_days(nowdate(), -3)) + pr2 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100, + posting_date=add_days(nowdate(), -2)) + pr3 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100, + posting_date=nowdate()) + + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 10) + self.assertEqual(pr3_balance, 12) + + # post backdated stock reco in between + sr4 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=6, rate=100, + posting_date=add_days(nowdate(), -1)) + pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr3_balance, 7) + + # post backdated stock reco at the start + sr5 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=8, rate=100, + posting_date=add_days(nowdate(), -4)) + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, + "qty_after_transaction") + sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 18) + self.assertEqual(pr2_balance, 19) + self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected + + # cancel backdated stock reco and check future impact + sr5.cancel() + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, + "qty_after_transaction") + sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 10) + self.assertEqual(pr2_balance, 11) + self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected + + # teardown + sr4.cancel() + pr3.cancel() + pr2.cancel() + pr1.cancel() + + def test_backdated_stock_reco_future_negative_stock(self): + """ + Test if a backdated stock reco causes future negative stock and is blocked. + ------------------------------------------- + Var | Doc | Qty | Balance + ------------------------------------------- + PR1 | PR | 10 | 10 (posting date: today-2) + SR3 | Reco | 0 | 1 (posting date: today-1) [backdated & blocked] + DN2 | DN | -2 | 8(-1) (posting date: today) + """ + from erpnext.stock.stock_ledger import NegativeStockError + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code = "Backdated-Reco-Item" + warehouse = "_Test Warehouse - _TC" + create_item(item_code) + + negative_stock_setting = frappe.db.get_single_value("Stock Settings", "allow_negative_stock") + frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 0) + + pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100, + posting_date=add_days(nowdate(), -2)) + dn2 = create_delivery_note(item_code=item_code, warehouse=warehouse, qty=2, rate=120, + posting_date=nowdate()) + + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + dn2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": dn2.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 10) + self.assertEqual(dn2_balance, 8) + + # check if stock reco is blocked + sr3 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=1, rate=100, + posting_date=add_days(nowdate(), -1), do_not_submit=True) + self.assertRaises(NegativeStockError, sr3.submit) + + # teardown + frappe.db.set_value("Stock Settings", None, "allow_negative_stock", negative_stock_setting) + sr3.cancel() + dn2.cancel() + pr1.cancel() + + def test_valid_batch(self): + create_batch_item_with_batch("Testing Batch Item 1", "001") + create_batch_item_with_batch("Testing Batch Item 2", "002") + sr = create_stock_reconciliation(item_code="Testing Batch Item 1", qty=1, rate=100, batch_no="002" + , do_not_submit=True) + self.assertRaises(frappe.ValidationError, sr.submit) + +def create_batch_item_with_batch(item_name, batch_id): + batch_item_doc = create_item(item_name, is_stock_item=1) + if not batch_item_doc.has_batch_no: + batch_item_doc.has_batch_no = 1 + batch_item_doc.create_new_batch = 1 + batch_item_doc.save(ignore_permissions=True) + + if not frappe.db.exists('Batch', batch_id): + b = frappe.new_doc('Batch') + b.item = item_name + b.batch_id = batch_id + b.save() + def insert_existing_sle(warehouse): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -231,6 +400,12 @@ def create_batch_or_serial_no_items(): serial_item_doc.serial_no_series = "SRSI.####" serial_item_doc.save(ignore_permissions=True) + serial_item_doc = create_item("Stock-Reco-Serial-Item-2", is_stock_item=1) + if not serial_item_doc.has_serial_no: + serial_item_doc.has_serial_no = 1 + serial_item_doc.serial_no_series = "SRSII.####" + serial_item_doc.save(ignore_permissions=True) + batch_item_doc = create_item("Stock-Reco-batch-Item-1", is_stock_item=1) if not batch_item_doc.has_batch_no: batch_item_doc.has_batch_no = 1 diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index cf5d98d0923..2a9dcfb67ed 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -23,7 +23,10 @@ "allow_negative_stock", "show_barcode_field", "clean_description_html", + "quality_inspection_settings_section", "action_if_quality_inspection_is_not_submitted", + "column_break_21", + "action_if_quality_inspection_is_rejected", "section_break_7", "automatically_set_serial_nos_based_on_fifo", "set_qty_in_transactions_based_on_serial_no_input", @@ -264,6 +267,22 @@ { "fieldname": "column_break_31", "fieldtype": "Column Break" + }, + { + "fieldname": "quality_inspection_settings_section", + "fieldtype": "Section Break", + "label": "Quality Inspection Settings" + }, + { + "fieldname": "column_break_21", + "fieldtype": "Column Break" + }, + { + "default": "Stop", + "fieldname": "action_if_quality_inspection_is_rejected", + "fieldtype": "Select", + "label": "Action If Quality Inspection Is Rejected", + "options": "Stop\nWarn" } ], "icon": "icon-cog", @@ -271,7 +290,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-04-30 17:27:42.709231", + "modified": "2021-07-10 16:17:42.159829", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 95478f61f0a..e3981c913e1 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -11,6 +11,7 @@ from frappe.test_runner import make_test_records import erpnext from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.accounts.doctype.account.test_account import get_inventory_account, create_account +from erpnext.stock.doctype.item.test_item import create_item test_records = frappe.get_test_records('Warehouse') @@ -92,6 +93,39 @@ class TestWarehouse(unittest.TestCase): self.assertTrue(frappe.db.get_value("Warehouse", filters={"account": "Test Warehouse for Merging 2 - TCP1"})) + def test_unlinking_warehouse_from_item_defaults(self): + company = "_Test Company" + + warehouse_names = [f'_Test Warehouse {i} for Unlinking' for i in range(2)] + warehouse_ids = [] + for warehouse in warehouse_names: + warehouse_id = create_warehouse(warehouse, company=company) + warehouse_ids.append(warehouse_id) + + item_names = [f'_Test Item {i} for Unlinking' for i in range(2)] + for item, warehouse in zip(item_names, warehouse_ids): + create_item(item, warehouse=warehouse, company=company) + + # Delete warehouses + for warehouse in warehouse_ids: + frappe.delete_doc("Warehouse", warehouse) + + # Check Item existance + for item in item_names: + self.assertTrue( + bool(frappe.db.exists("Item", item)), + f"{item} doesn't exist" + ) + + item_doc = frappe.get_doc("Item", item) + for item_default in item_doc.item_defaults: + self.assertNotIn( + item_default.default_warehouse, + warehouse_ids, + f"{item} linked to {item_default.default_warehouse} in {warehouse_ids}." + ) + + def create_warehouse(warehouse_name, properties=None, company=None): if not company: company = "_Test Company" diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 2062bddc7c9..3abc13907cf 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -54,6 +54,7 @@ class Warehouse(NestedSet): throw(_("Child warehouse exists for this warehouse. You can not delete this warehouse.")) self.update_nsm_model() + self.unlink_from_items() def check_if_sle_exists(self): return frappe.db.sql("""select name from `tabStock Ledger Entry` @@ -138,6 +139,12 @@ class Warehouse(NestedSet): self.save() return 1 + def unlink_from_items(self): + frappe.db.sql(""" + update `tabItem Default` + set default_warehouse=NULL + where default_warehouse=%s""", self.name) + @frappe.whitelist() def get_children(doctype, parent=None, company=None, is_root=False): if is_root: diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index d1dcdc21c87..cf52803fca8 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -74,9 +74,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru update_party_blanket_order(args, out) - if not doc or cint(doc.get('is_return')) == 0: - # get price list rate only if the invoice is not a credit or debit note - get_price_list_rate(args, item, out) + + get_price_list_rate(args, item, out) if args.customer and cint(args.is_pos): out.update(get_pos_profile_item_details(args.company, args, update_data=True)) @@ -436,18 +435,37 @@ def get_barcode_data(items_list): return itemwise_barcode @frappe.whitelist() -def get_item_tax_info(company, tax_category, item_codes): +def get_item_tax_info(company, tax_category, item_codes, item_rates=None, item_tax_templates=None): out = {} - if isinstance(item_codes, string_types): + + if item_tax_templates is None: + item_tax_templates = {} + + if item_rates is None: + item_rates = {} + + if isinstance(item_codes, (str,)): item_codes = json.loads(item_codes) + if isinstance(item_rates, (str,)): + item_rates = json.loads(item_rates) + + if isinstance(item_tax_templates, (str,)): + item_tax_templates = json.loads(item_tax_templates) + for item_code in item_codes: - if not item_code or item_code in out: + if not item_code or item_code[1] in out or not item_tax_templates.get(item_code[1]): continue - out[item_code] = {} - item = frappe.get_cached_doc("Item", item_code) - get_item_tax_template({"company": company, "tax_category": tax_category}, item, out[item_code]) - out[item_code]["item_tax_rate"] = get_item_tax_map(company, out[item_code].get("item_tax_template"), as_json=True) + + out[item_code[1]] = {} + item = frappe.get_cached_doc("Item", item_code[0]) + args = {"company": company, "tax_category": tax_category, "net_rate": item_rates.get(item_code[1])} + + if item_tax_templates: + args.update({"item_tax_template": item_tax_templates.get(item_code[1])}) + + get_item_tax_template(args, item, out[item_code[1]]) + out[item_code[1]]["item_tax_rate"] = get_item_tax_map(company, out[item_code[1]].get("item_tax_template"), as_json=True) return out @@ -459,9 +477,7 @@ def get_item_tax_template(args, item, out): } """ item_tax_template = args.get("item_tax_template") - - if not item_tax_template: - item_tax_template = _get_item_tax_template(args, item.taxes, out) + item_tax_template = _get_item_tax_template(args, item.taxes, out) if not item_tax_template: item_group = item.item_group @@ -478,12 +494,13 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False): for tax in taxes: tax_company = frappe.get_value("Item Tax Template", tax.item_tax_template, 'company') - if tax.valid_from and tax_company == args['company']: + if (tax.valid_from or tax.maximum_net_rate) and tax_company == args['company']: # In purchase Invoice first preference will be given to supplier invoice date # if supplier date is not present then posting date validation_date = args.get('transaction_date') or args.get('bill_date') or args.get('posting_date') - if getdate(tax.valid_from) <= getdate(validation_date): + if getdate(tax.valid_from) <= getdate(validation_date) \ + and is_within_valid_range(args, tax): taxes_with_validity.append(tax) else: if tax_company == args['company']: @@ -502,12 +519,26 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False): if not taxes_with_validity and (not taxes_with_no_validity): return None + # do not change if already a valid template + if args.get('item_tax_template') in {t.item_tax_template for t in taxes}: + out["item_tax_template"] = args.get('item_tax_template') + return args.get('item_tax_template') + for tax in taxes: if cstr(tax.tax_category) == cstr(args.get("tax_category")): out["item_tax_template"] = tax.item_tax_template return tax.item_tax_template return None +def is_within_valid_range(args, tax): + if not flt(tax.maximum_net_rate): + # No range specified, just ignore + return True + elif flt(tax.minimum_net_rate) <= flt(args.get('net_rate')) <= flt(tax.maximum_net_rate): + return True + + return False + @frappe.whitelist() def get_item_tax_map(company, item_tax_template, as_json=True): item_tax_map = {} @@ -775,10 +806,14 @@ def check_packing_list(price_list_rate_name, desired_qty, item_code): def validate_conversion_rate(args, meta): from erpnext.controllers.accounts_controller import validate_conversion_rate - if (not args.conversion_rate - and args.currency==frappe.get_cached_value('Company', args.company, "default_currency")): + company_currency = frappe.get_cached_value('Company', args.company, "default_currency") + if (not args.conversion_rate and args.currency==company_currency): args.conversion_rate = 1.0 + if (not args.ignore_conversion_rate and args.conversion_rate == 1 and args.currency!=company_currency): + args.conversion_rate = get_exchange_rate(args.currency, + company_currency, args.transaction_date, "for_buying") or 1.0 + # validate currency conversion rate validate_conversion_rate(args.currency, args.conversion_rate, meta.get_label("conversion_rate"), args.company) diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/__init__.py b/erpnext/stock/report/incorrect_balance_qty_after_transaction/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.js b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.js new file mode 100644 index 00000000000..bf11277d9c4 --- /dev/null +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.js @@ -0,0 +1,27 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Incorrect Balance Qty After Transaction"] = { + "filters": [ + { + label: __("Company"), + fieldtype: "Link", + fieldname: "company", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + label: __('Item Code'), + fieldtype: 'Link', + fieldname: 'item_code', + options: 'Item' + }, + { + label: __('Warehouse'), + fieldtype: 'Link', + fieldname: 'warehouse' + } + ] +}; diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.json b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.json new file mode 100644 index 00000000000..a5815bcca49 --- /dev/null +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-05-12 16:47:58.717853", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-05-12 16:48:28.347575", + "modified_by": "Administrator", + "module": "Stock", + "name": "Incorrect Balance Qty After Transaction", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Incorrect Balance Qty After Transaction", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Stock Manager" + }, + { + "role": "Purchase User" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py new file mode 100644 index 00000000000..cf174c93682 --- /dev/null +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py @@ -0,0 +1,111 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from six import iteritems +from frappe.utils import flt + +def execute(filters=None): + columns, data = [], [] + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_data(filters): + data = get_stock_ledger_entries(filters) + itewise_balance_qty = {} + + for row in data: + key = (row.item_code, row.warehouse) + itewise_balance_qty.setdefault(key, []).append(row) + + res = validate_data(itewise_balance_qty) + return res + +def validate_data(itewise_balance_qty): + res = [] + for key, data in iteritems(itewise_balance_qty): + row = get_incorrect_data(data) + if row: + res.append(row) + res.append({}) + + return res + +def get_incorrect_data(data): + balance_qty = 0.0 + for row in data: + balance_qty += row.actual_qty + if row.voucher_type == "Stock Reconciliation" and not row.batch_no: + balance_qty = flt(row.qty_after_transaction) + + row.expected_balance_qty = balance_qty + if abs(flt(row.expected_balance_qty) - flt(row.qty_after_transaction)) > 0.5: + row.differnce = abs(flt(row.expected_balance_qty) - flt(row.qty_after_transaction)) + return row + +def get_stock_ledger_entries(report_filters): + filters = {} + fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'actual_qty', + 'posting_date', 'posting_time', 'company', 'warehouse', 'qty_after_transaction', 'batch_no'] + + for field in ['warehouse', 'item_code', 'company']: + if report_filters.get(field): + filters[field] = report_filters.get(field) + + return frappe.get_all('Stock Ledger Entry', fields = fields, filters = filters, + order_by = 'timestamp(posting_date, posting_time) asc, creation asc') + +def get_columns(): + return [{ + 'label': _('Id'), + 'fieldtype': 'Link', + 'fieldname': 'name', + 'options': 'Stock Ledger Entry', + 'width': 120 + }, { + 'label': _('Posting Date'), + 'fieldtype': 'Date', + 'fieldname': 'posting_date', + 'width': 110 + }, { + 'label': _('Voucher Type'), + 'fieldtype': 'Link', + 'fieldname': 'voucher_type', + 'options': 'DocType', + 'width': 120 + }, { + 'label': _('Voucher No'), + 'fieldtype': 'Dynamic Link', + 'fieldname': 'voucher_no', + 'options': 'voucher_type', + 'width': 120 + }, { + 'label': _('Item Code'), + 'fieldtype': 'Link', + 'fieldname': 'item_code', + 'options': 'Item', + 'width': 120 + }, { + 'label': _('Warehouse'), + 'fieldtype': 'Link', + 'fieldname': 'warehouse', + 'options': 'Warehouse', + 'width': 120 + }, { + 'label': _('Expected Balance Qty'), + 'fieldtype': 'Float', + 'fieldname': 'expected_balance_qty', + 'width': 170 + }, { + 'label': _('Actual Balance Qty'), + 'fieldtype': 'Float', + 'fieldname': 'qty_after_transaction', + 'width': 150 + }, { + 'label': _('Difference'), + 'fieldtype': 'Float', + 'fieldname': 'differnce', + 'width': 110 + }] \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/__init__.py b/erpnext/stock/report/incorrect_serial_no_valuation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.js b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.js new file mode 100644 index 00000000000..c62d48081c2 --- /dev/null +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.js @@ -0,0 +1,35 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Incorrect Serial No Valuation"] = { + "filters": [ + { + label: __('Item Code'), + fieldtype: 'Link', + fieldname: 'item_code', + options: 'Item', + get_query: function() { + return { + filters: { + 'has_serial_no': 1 + } + } + } + }, + { + label: __('From Date'), + fieldtype: 'Date', + fieldname: 'from_date', + reqd: 1, + default: frappe.defaults.get_user_default("year_start_date") + }, + { + label: __('To Date'), + fieldtype: 'Date', + fieldname: 'to_date', + reqd: 1, + default: frappe.defaults.get_user_default("year_end_date") + } + ] +}; diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.json b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.json new file mode 100644 index 00000000000..cc384a5bd03 --- /dev/null +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.json @@ -0,0 +1,36 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-05-13 13:07:00.767845", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "modified": "2021-05-13 13:07:00.767845", + "modified_by": "Administrator", + "module": "Stock", + "name": "Incorrect Serial No Valuation", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Incorrect Serial No Valuation", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Accounts User" + }, + { + "role": "Stock Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py new file mode 100644 index 00000000000..e54cf4c66c7 --- /dev/null +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py @@ -0,0 +1,148 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import copy +from frappe import _ +from six import iteritems +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + +def execute(filters=None): + columns, data = [], [] + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_data(filters): + data = get_stock_ledger_entries(filters) + serial_nos_data = prepare_serial_nos(data) + data = get_incorrect_serial_nos(serial_nos_data) + + return data + +def prepare_serial_nos(data): + serial_no_wise_data = {} + for row in data: + if not row.serial_nos: + continue + + for serial_no in get_serial_nos(row.serial_nos): + sle = copy.deepcopy(row) + sle.serial_no = serial_no + sle.qty = 1 if sle.actual_qty > 0 else -1 + sle.valuation_rate = sle.valuation_rate if sle.actual_qty > 0 else sle.valuation_rate * -1 + serial_no_wise_data.setdefault(serial_no, []).append(sle) + + return serial_no_wise_data + +def get_incorrect_serial_nos(serial_nos_data): + result = [] + + total_value = frappe._dict({'qty': 0, 'valuation_rate': 0, 'serial_no': frappe.bold(_('Balance'))}) + + for serial_no, data in iteritems(serial_nos_data): + total_dict = frappe._dict({'qty': 0, 'valuation_rate': 0, 'serial_no': frappe.bold(_('Total'))}) + + if check_incorrect_serial_data(data, total_dict): + result.extend(data) + + total_value.qty += total_dict.qty + total_value.valuation_rate += total_dict.valuation_rate + + result.append(total_dict) + result.append({}) + + result.append(total_value) + + return result + +def check_incorrect_serial_data(data, total_dict): + incorrect_data = False + for row in data: + total_dict.qty += row.qty + total_dict.valuation_rate += row.valuation_rate + + if ((total_dict.qty == 0 and abs(total_dict.valuation_rate) > 0) or total_dict.qty < 0): + incorrect_data = True + + return incorrect_data + +def get_stock_ledger_entries(report_filters): + fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'serial_no as serial_nos', 'actual_qty', + 'posting_date', 'posting_time', 'company', 'warehouse', '(stock_value_difference / actual_qty) as valuation_rate'] + + filters = {'serial_no': ("is", "set")} + + if report_filters.get('item_code'): + filters['item_code'] = report_filters.get('item_code') + + if report_filters.get('from_date') and report_filters.get('to_date'): + filters['posting_date'] = ('between', [report_filters.get('from_date'), report_filters.get('to_date')]) + + return frappe.get_all('Stock Ledger Entry', fields = fields, filters = filters, + order_by = 'timestamp(posting_date, posting_time) asc, creation asc') + +def get_columns(): + return [{ + 'label': _('Company'), + 'fieldtype': 'Link', + 'fieldname': 'company', + 'options': 'Company', + 'width': 120 + }, { + 'label': _('Id'), + 'fieldtype': 'Link', + 'fieldname': 'name', + 'options': 'Stock Ledger Entry', + 'width': 120 + }, { + 'label': _('Posting Date'), + 'fieldtype': 'Date', + 'fieldname': 'posting_date', + 'width': 90 + }, { + 'label': _('Posting Time'), + 'fieldtype': 'Time', + 'fieldname': 'posting_time', + 'width': 90 + }, { + 'label': _('Voucher Type'), + 'fieldtype': 'Link', + 'fieldname': 'voucher_type', + 'options': 'DocType', + 'width': 100 + }, { + 'label': _('Voucher No'), + 'fieldtype': 'Dynamic Link', + 'fieldname': 'voucher_no', + 'options': 'voucher_type', + 'width': 110 + }, { + 'label': _('Item Code'), + 'fieldtype': 'Link', + 'fieldname': 'item_code', + 'options': 'Item', + 'width': 120 + }, { + 'label': _('Warehouse'), + 'fieldtype': 'Link', + 'fieldname': 'warehouse', + 'options': 'Warehouse', + 'width': 120 + }, { + 'label': _('Serial No'), + 'fieldtype': 'Link', + 'fieldname': 'serial_no', + 'options': 'Serial No', + 'width': 100 + }, { + 'label': _('Qty'), + 'fieldtype': 'Float', + 'fieldname': 'qty', + 'width': 80 + }, { + 'label': _('Valuation Rate (In / Out)'), + 'fieldtype': 'Currency', + 'fieldname': 'valuation_rate', + 'width': 110 + }] \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_stock_value_report/__init__.py b/erpnext/stock/report/incorrect_stock_value_report/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.js b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.js new file mode 100644 index 00000000000..ff424807e3e --- /dev/null +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.js @@ -0,0 +1,36 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Incorrect Stock Value Report"] = { + "filters": [ + { + "label": __("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "default": frappe.defaults.get_user_default("Company") + }, + { + "label": __("Account"), + "fieldname": "account", + "fieldtype": "Link", + "options": "Account", + get_query: function() { + var company = frappe.query_report.get_filter_value('company'); + return { + filters: { + "account_type": "Stock", + "company": company + } + } + } + }, + { + "label": __("From Date"), + "fieldname": "from_date", + "fieldtype": "Date" + } + ] +}; diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.json b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.json new file mode 100644 index 00000000000..a7e9f203f7b --- /dev/null +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-06-22 15:35:05.148177", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-06-22 15:35:05.148177", + "modified_by": "Administrator", + "module": "Stock", + "name": "Incorrect Stock Value Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Incorrect Stock Value Report", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Accounts Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py new file mode 100644 index 00000000000..a7243878eb8 --- /dev/null +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py @@ -0,0 +1,141 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import erpnext +from frappe import _ +from six import iteritems +from frappe.utils import add_days, today, getdate +from erpnext.stock.utils import get_stock_value_on +from erpnext.accounts.utils import get_stock_and_account_balance + +def execute(filters=None): + if not erpnext.is_perpetual_inventory_enabled(filters.company): + frappe.throw(_("Perpetual inventory required for the company {0} to view this report.") + .format(filters.company)) + + data = get_data(filters) + columns = get_columns(filters) + + return columns, data + +def get_unsync_date(filters): + date = filters.from_date + if not date: + date = frappe.db.sql(""" SELECT min(posting_date) from `tabStock Ledger Entry`""") + date = date[0][0] + + if not date: + return + + while getdate(date) < getdate(today()): + account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(posting_date=date, + company=filters.company, account = filters.account) + + if abs(account_bal - stock_bal) > 0.1: + return date + + date = add_days(date, 1) + +def get_data(report_filters): + from_date = get_unsync_date(report_filters) + + if not from_date: + return [] + + result = [] + + voucher_wise_dict = {} + data = frappe.db.sql(''' + SELECT + name, posting_date, posting_time, voucher_type, voucher_no, + stock_value_difference, stock_value, warehouse, item_code + FROM + `tabStock Ledger Entry` + WHERE + posting_date + = %s and company = %s + and is_cancelled = 0 + ORDER BY timestamp(posting_date, posting_time) asc, creation asc + ''', (from_date, report_filters.company), as_dict=1) + + for d in data: + voucher_wise_dict.setdefault((d.item_code, d.warehouse), []).append(d) + + closing_date = add_days(from_date, -1) + for key, stock_data in iteritems(voucher_wise_dict): + prev_stock_value = get_stock_value_on(posting_date = closing_date, item_code=key[0], warehouse =key[1]) + for data in stock_data: + expected_stock_value = prev_stock_value + data.stock_value_difference + if abs(data.stock_value - expected_stock_value) > 0.1: + data.difference_value = abs(data.stock_value - expected_stock_value) + data.expected_stock_value = expected_stock_value + result.append(data) + + return result + +def get_columns(filters): + return [ + { + "label": _("Stock Ledger ID"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Stock Ledger Entry", + "width": "80" + }, + { + "label": _("Posting Date"), + "fieldname": "posting_date", + "fieldtype": "Date" + }, + { + "label": _("Posting Time"), + "fieldname": "posting_time", + "fieldtype": "Time" + }, + { + "label": _("Voucher Type"), + "fieldname": "voucher_type", + "width": "110" + }, + { + "label": _("Voucher No"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": "110" + }, + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": "110" + }, + { + "label": _("Warehouse"), + "fieldname": "warehouse", + "fieldtype": "Link", + "options": "Warehouse", + "width": "110" + }, + { + "label": _("Expected Stock Value"), + "fieldname": "expected_stock_value", + "fieldtype": "Currency", + "width": "150" + }, + { + "label": _("Stock Value"), + "fieldname": "stock_value", + "fieldtype": "Currency", + "width": "120" + }, + { + "label": _("Difference Value"), + "fieldname": "difference_value", + "fieldtype": "Currency", + "width": "150" + } + ] \ No newline at end of file diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index fc82c789cc7..c15d1eda7dc 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -6,13 +6,14 @@ import frappe import erpnext import copy from frappe import _ -from frappe.utils import cint, flt, cstr, now, get_link_to_form +from frappe.utils import cint, flt, cstr, now, get_link_to_form, getdate from frappe.model.meta import get_field_precision from erpnext.stock.utils import get_valuation_method, get_incoming_outgoing_rate_for_cancel from erpnext.stock.utils import get_bin import json from six import iteritems + # future reposting class NegativeStockError(frappe.ValidationError): pass class SerialNoExistsInFutureTransaction(frappe.ValidationError): @@ -22,6 +23,7 @@ _exceptions = frappe.local('stockledger_exceptions') # _exceptions = [] def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): + from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: from erpnext.stock.utils import update_bin @@ -30,6 +32,9 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc validate_cancellation(sl_entries) set_as_cancel(sl_entries[0].get('voucher_type'), sl_entries[0].get('voucher_no')) + args = get_args_for_future_sle(sl_entries[0]) + future_sle_exists(args, sl_entries) + for sle in sl_entries: if sle.serial_no: validate_serial_no(sle) @@ -51,8 +56,21 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) args = sle_doc.as_dict() + + if sle.get("voucher_type") == "Stock Reconciliation": + # preserve previous_qty_after_transaction for qty reposting + args.previous_qty_after_transaction = sle.get("previous_qty_after_transaction") + update_bin(args, allow_negative_stock, via_landed_cost_voucher) +def get_args_for_future_sle(row): + return frappe._dict({ + 'voucher_type': row.get('voucher_type'), + 'voucher_no': row.get('voucher_no'), + 'posting_date': row.get('posting_date'), + 'posting_time': row.get('posting_time') + }) + def validate_serial_no(sle): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos for sn in get_serial_nos(sle.serial_no): @@ -113,7 +131,13 @@ def repost_future_sle(args=None, voucher_type=None, voucher_no=None, allow_negat if not args and voucher_type and voucher_no: args = get_args_for_voucher(voucher_type, voucher_no) - distinct_item_warehouses = [(d.item_code, d.warehouse) for d in args] + distinct_item_warehouses = {} + for i, d in enumerate(args): + distinct_item_warehouses.setdefault((d.item_code, d.warehouse), frappe._dict({ + "reposting_status": False, + "sle": d, + "args_idx": i + })) i = 0 while i < len(args): @@ -122,13 +146,21 @@ def repost_future_sle(args=None, voucher_type=None, voucher_no=None, allow_negat "warehouse": args[i].warehouse, "posting_date": args[i].posting_date, "posting_time": args[i].posting_time, - "creation": args[i].get("creation") + "creation": args[i].get("creation"), + "distinct_item_warehouses": distinct_item_warehouses }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher) - for item_wh, new_sle in iteritems(obj.new_items): - if item_wh not in distinct_item_warehouses: - args.append(new_sle) + distinct_item_warehouses[(args[i].item_code, args[i].warehouse)].reposting_status = True + if obj.new_items_found: + for item_wh, data in iteritems(distinct_item_warehouses): + if ('args_idx' not in data and not data.reposting_status) or (data.sle_changed and data.reposting_status): + data.args_idx = len(args) + args.append(data.sle) + elif data.sle_changed and not data.reposting_status: + args[data.args_idx] = data.sle + + data.sle_changed = False i += 1 def get_args_for_voucher(voucher_type, voucher_no): @@ -169,11 +201,12 @@ class update_entries_after(object): self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") self.get_precision() self.valuation_method = get_valuation_method(self.item_code) - self.new_items = {} + + self.new_items_found = False + self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) self.data = frappe._dict() self.initialize_previous_data(self.args) - self.build() def get_precision(self): @@ -203,7 +236,7 @@ class update_entries_after(object): """ self.data.setdefault(args.warehouse, frappe._dict()) warehouse_dict = self.data[args.warehouse] - previous_sle = self.get_previous_sle_of_current_voucher(args) + previous_sle = get_previous_sle_of_current_voucher(args) warehouse_dict.previous_sle = previous_sle for key in ("qty_after_transaction", "valuation_rate", "stock_value"): @@ -215,29 +248,6 @@ class update_entries_after(object): "stock_value_difference": 0.0 }) - def get_previous_sle_of_current_voucher(self, args): - """get stock ledger entries filtered by specific posting datetime conditions""" - - args['time_format'] = '%H:%i:%s' - if not args.get("posting_date"): - args["posting_date"] = "1900-01-01" - if not args.get("posting_time"): - args["posting_time"] = "00:00" - - sle = frappe.db.sql(""" - select *, timestamp(posting_date, posting_time) as "timestamp" - from `tabStock Ledger Entry` - where item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) - order by timestamp(posting_date, posting_time) desc, creation desc - limit 1 - for update""", args, as_dict=1) - - return sle[0] if sle else frappe._dict() - - def build(self): from erpnext.controllers.stock_controller import future_sle_exists @@ -302,11 +312,29 @@ class update_entries_after(object): elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse == self.args.warehouse: return entries_to_fix elif dependant_sle.item_code != self.item_code: - if (dependant_sle.item_code, dependant_sle.warehouse) not in self.new_items: - self.new_items[(dependant_sle.item_code, dependant_sle.warehouse)] = dependant_sle + self.update_distinct_item_warehouses(dependant_sle) return entries_to_fix elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data: return entries_to_fix + else: + return self.append_future_sle_for_dependant(dependant_sle, entries_to_fix) + + def update_distinct_item_warehouses(self, dependant_sle): + key = (dependant_sle.item_code, dependant_sle.warehouse) + val = frappe._dict({ + "sle": dependant_sle + }) + if key not in self.distinct_item_warehouses: + self.distinct_item_warehouses[key] = val + self.new_items_found = True + else: + existing_sle_posting_date = self.distinct_item_warehouses[key].get("sle", {}).get("posting_date") + if getdate(dependant_sle.posting_date) < getdate(existing_sle_posting_date): + val.sle_changed = True + self.distinct_item_warehouses[key] = val + self.new_items_found = True + + def append_future_sle_for_dependant(self, dependant_sle, entries_to_fix): self.initialize_previous_data(dependant_sle) args = self.data[dependant_sle.warehouse].previous_sle \ @@ -399,6 +427,7 @@ class update_entries_after(object): rate = 0 # Material Transfer, Repack, Manufacturing if sle.voucher_type == "Stock Entry": + self.recalculate_amounts_in_stock_entry(sle.voucher_no) rate = frappe.db.get_value("Stock Entry Detail", sle.voucher_detail_no, "valuation_rate") # Sales and Purchase Return elif sle.voucher_type in ("Purchase Receipt", "Purchase Invoice", "Delivery Note", "Sales Invoice"): @@ -448,7 +477,11 @@ class update_entries_after(object): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) # Update outgoing item's rate, recalculate FG Item's rate and total incoming/outgoing amount - stock_entry = frappe.get_doc("Stock Entry", sle.voucher_no, for_update=True) + if not sle.dependant_sle_voucher_detail_no: + self.recalculate_amounts_in_stock_entry(sle.voucher_no) + + def recalculate_amounts_in_stock_entry(self, voucher_no): + stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True) stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False) stock_entry.db_update() for d in stock_entry.items: @@ -472,7 +505,7 @@ class update_entries_after(object): frappe.db.set_value("Purchase Receipt Item Supplied", sle.voucher_detail_no, "rate", outgoing_rate) # Recalculate subcontracted item's rate in case of subcontracted purchase receipt/invoice - if frappe.db.get_value(sle.voucher_type, sle.voucher_no, "is_subcontracted"): + if frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_subcontracted") == 'Yes': 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): @@ -722,6 +755,35 @@ class update_entries_after(object): bin_doc.flags.via_stock_ledger_entry = True bin_doc.save(ignore_permissions=True) + +def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False): + """get stock ledger entries filtered by specific posting datetime conditions""" + + args['time_format'] = '%H:%i:%s' + if not args.get("posting_date"): + args["posting_date"] = "1900-01-01" + if not args.get("posting_time"): + args["posting_time"] = "00:00" + + voucher_condition = "" + if exclude_current_voucher: + voucher_no = args.get("voucher_no") + voucher_condition = f"and voucher_no != '{voucher_no}'" + + sle = frappe.db.sql(""" + select *, timestamp(posting_date, posting_time) as "timestamp" + from `tabStock Ledger Entry` + where item_code = %(item_code)s + and warehouse = %(warehouse)s + and is_cancelled = 0 + {voucher_condition} + and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) + order by timestamp(posting_date, posting_time) desc, creation desc + limit 1 + for update""".format(voucher_condition=voucher_condition), args, as_dict=1) + + return sle[0] if sle else frappe._dict() + def get_previous_sle(args, for_update=False): """ get the last sle on or before the current time-bucket, @@ -850,9 +912,24 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, return valuation_rate def update_qty_in_future_sle(args, allow_negative_stock=None): + """Recalculate Qty after Transaction in future SLEs based on current SLE.""" + datetime_limit_condition = "" + qty_shift = args.actual_qty + + # find difference/shift in qty caused by stock reconciliation + if args.voucher_type == "Stock Reconciliation": + qty_shift = get_stock_reco_qty_shift(args) + + # find the next nearest stock reco so that we only recalculate SLEs till that point + next_stock_reco_detail = get_next_stock_reco(args) + if next_stock_reco_detail: + detail = next_stock_reco_detail[0] + # add condition to update SLEs before this date & time + datetime_limit_condition = get_datetime_limit_condition(detail) + frappe.db.sql(""" update `tabStock Ledger Entry` - set qty_after_transaction = qty_after_transaction + {qty} + set qty_after_transaction = qty_after_transaction + {qty_shift} where item_code = %(item_code)s and warehouse = %(warehouse)s @@ -864,15 +941,70 @@ def update_qty_in_future_sle(args, allow_negative_stock=None): and creation > %(creation)s ) ) - """.format(qty=args.actual_qty), args) + {datetime_limit_condition} + """.format(qty_shift=qty_shift, datetime_limit_condition=datetime_limit_condition), args) validate_negative_qty_in_future_sle(args, allow_negative_stock) +def get_stock_reco_qty_shift(args): + stock_reco_qty_shift = 0 + if args.get("is_cancelled"): + if args.get("previous_qty_after_transaction"): + # get qty (balance) that was set at submission + last_balance = args.get("previous_qty_after_transaction") + stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance) + else: + stock_reco_qty_shift = flt(args.actual_qty) + else: + # reco is being submitted + last_balance = get_previous_sle_of_current_voucher(args, + exclude_current_voucher=True).get("qty_after_transaction") + + if last_balance is not None: + stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance) + else: + stock_reco_qty_shift = args.qty_after_transaction + + return stock_reco_qty_shift + +def get_next_stock_reco(args): + """Returns next nearest stock reconciliaton's details.""" + + return frappe.db.sql(""" + select + name, posting_date, posting_time, creation, voucher_no + from + `tabStock Ledger Entry` + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and voucher_type = 'Stock Reconciliation' + and voucher_no != %(voucher_no)s + and is_cancelled = 0 + and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s) + or ( + timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) + and creation > %(creation)s + ) + ) + limit 1 + """, args, as_dict=1) + +def get_datetime_limit_condition(detail): + return f""" + and + (timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}') + or ( + timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}') + and creation < '{detail.creation}' + ) + )""" + def validate_negative_qty_in_future_sle(args, allow_negative_stock=None): allow_negative_stock = allow_negative_stock \ or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - if args.actual_qty < 0 and not allow_negative_stock: + if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock: sle = get_future_sle_with_negative_qty(args) if sle: message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format( diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 034d3ebbb54..b57b2aa6b8f 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -177,7 +177,7 @@ def get_bin(item_code, warehouse): return bin_obj def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False): - is_stock_item = frappe.db.get_value('Item', args.get("item_code"), 'is_stock_item') + is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item') if is_stock_item: bin = get_bin(args.get("item_code"), args.get("warehouse")) bin.update_stock(args, allow_negative_stock, via_landed_cost_voucher) @@ -314,13 +314,16 @@ def update_included_uom_in_report(columns, result, include_uom, conversion_facto for row_idx, row in enumerate(result): data = row.items() if is_dict_obj else enumerate(row) for key, value in data: - if key not in convertible_columns or not conversion_factors[row_idx-1]: + if key not in convertible_columns: continue + # If no conversion factor for the UOM, defaults to 1 + if not conversion_factors[row_idx]: + conversion_factors[row_idx] = 1 if convertible_columns.get(key) == 'rate': - new_value = flt(value) * conversion_factors[row_idx-1] + new_value = flt(value) * conversion_factors[row_idx] else: - new_value = flt(value) / conversion_factors[row_idx-1] + new_value = flt(value) / conversion_factors[row_idx] if not is_dict_obj: row.insert(key+1, new_value) @@ -386,4 +389,4 @@ def is_reposting_item_valuation_in_progress(): reposting_in_progress = frappe.db.exists("Repost Item Valuation", {'docstatus': 1, 'status': ['in', ['Queued','In Progress']]}) if reposting_in_progress: - frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1) \ No newline at end of file + frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1) diff --git a/erpnext/stock/workspace/stock/stock.json b/erpnext/stock/workspace/stock/stock.json index 3221dc4365c..529ce8eb61e 100644 --- a/erpnext/stock/workspace/stock/stock.json +++ b/erpnext/stock/workspace/stock/stock.json @@ -15,6 +15,7 @@ "hide_custom": 0, "icon": "stock", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "Stock", "links": [ @@ -653,9 +654,44 @@ "link_type": "Report", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Incorrect Data Report", + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Incorrect Serial No Qty and Valuation", + "link_to": "Incorrect Serial No Valuation", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Incorrect Balance Qty After Transaction", + "link_to": "Incorrect Balance Qty After Transaction", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Stock and Account Value Comparison", + "link_to": "Stock and Account Value Comparison", + "link_type": "Report", + "onboard": 0, + "type": "Link" } ], - "modified": "2020-12-01 13:38:36.282890", + "modified": "2021-05-13 13:10:24.914983", "modified_by": "Administrator", "module": "Stock", "name": "Stock", diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index a43381c5c6b..14712f89feb 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -119,7 +119,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Open\nReplied\nHold\nResolved\nClosed", + "options": "Open\nReplied\nOn Hold\nResolved\nClosed", "search_index": 1 }, { @@ -166,7 +166,7 @@ "options": "Service Level Agreement" }, { - "depends_on": "eval: doc.status != 'Replied';", + "depends_on": "eval: doc.status != 'Replied' && doc.service_level_agreement;", "fieldname": "response_by", "fieldtype": "Datetime", "label": "Response By", @@ -180,7 +180,7 @@ "read_only": 1 }, { - "depends_on": "eval: doc.status != 'Replied';", + "depends_on": "eval: doc.status != 'Replied' && doc.service_level_agreement;", "fieldname": "resolution_by", "fieldtype": "Datetime", "label": "Resolution By", @@ -410,7 +410,7 @@ "icon": "fa fa-ticket", "idx": 7, "links": [], - "modified": "2020-08-11 18:49:07.574769", + "modified": "2021-06-10 03:22:27.098898", "modified_by": "Administrator", "module": "Support", "name": "Issue", diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index dd6d647abc9..b9a65b6749e 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -5,10 +5,10 @@ from __future__ import unicode_literals import frappe import json from frappe import _ -from frappe import utils from frappe.model.document import Document -from frappe.utils import now_datetime -from datetime import datetime, timedelta +from frappe.utils import now_datetime, time_diff_in_seconds, get_datetime, date_diff +from frappe.core.utils import get_parent_doc +from datetime import timedelta from frappe.model.mapper import get_mapped_doc from frappe.utils.user import is_website_user from frappe.email.inbox import link_communication_to_document @@ -212,7 +212,129 @@ def make_issue_from_communication(communication, ignore_communication_links=Fals return issue.name +def get_time_in_timedelta(time): + """ + Converts datetime.time(10, 36, 55, 961454) to datetime.timedelta(seconds=38215) + """ + return timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + +def set_first_response_time(communication, method): + if communication.get('reference_doctype') == "Issue": + issue = get_parent_doc(communication) + if is_first_response(issue): + first_response_time = calculate_first_response_time(issue, get_datetime(issue.first_responded_on)) + issue.db_set("first_response_time", first_response_time) + +def is_first_response(issue): + responses = frappe.get_all('Communication', filters = {'reference_name': issue.name, 'sent_or_received': 'Sent'}) + if len(responses) == 1: + return True + return False + +def calculate_first_response_time(issue, first_responded_on): + issue_creation_date = issue.creation + issue_creation_time = get_time_in_seconds(issue_creation_date) + first_responded_on_in_seconds = get_time_in_seconds(first_responded_on) + support_hours = frappe.get_cached_doc("Service Level Agreement", issue.service_level_agreement).support_and_resolution + + if issue_creation_date.day == first_responded_on.day: + if is_work_day(issue_creation_date, support_hours): + start_time, end_time = get_working_hours(issue_creation_date, support_hours) + + # issue creation and response on the same day during working hours + if is_during_working_hours(issue_creation_date, support_hours) and is_during_working_hours(first_responded_on, support_hours): + return get_elapsed_time(issue_creation_date, first_responded_on) + + # issue creation is during working hours, but first response was after working hours + elif is_during_working_hours(issue_creation_date, support_hours): + return get_elapsed_time(issue_creation_time, end_time) + + # issue creation was before working hours but first response is during working hours + elif is_during_working_hours(first_responded_on, support_hours): + return get_elapsed_time(start_time, first_responded_on_in_seconds) + + # both issue creation and first response were after working hours + else: + return 1.0 # this should ideally be zero, but it gets reset when the next response is sent if the value is zero + + else: + return 1.0 + + else: + # response on the next day + if date_diff(first_responded_on, issue_creation_date) == 1: + first_response_time = 0 + else: + first_response_time = calculate_initial_frt(issue_creation_date, date_diff(first_responded_on, issue_creation_date)- 1, support_hours) + + # time taken on day of issue creation + if is_work_day(issue_creation_date, support_hours): + start_time, end_time = get_working_hours(issue_creation_date, support_hours) + + if is_during_working_hours(issue_creation_date, support_hours): + first_response_time += get_elapsed_time(issue_creation_time, end_time) + elif is_before_working_hours(issue_creation_date, support_hours): + first_response_time += get_elapsed_time(start_time, end_time) + + # time taken on day of first response + if is_work_day(first_responded_on, support_hours): + start_time, end_time = get_working_hours(first_responded_on, support_hours) + + if is_during_working_hours(first_responded_on, support_hours): + first_response_time += get_elapsed_time(start_time, first_responded_on_in_seconds) + elif not is_before_working_hours(first_responded_on, support_hours): + first_response_time += get_elapsed_time(start_time, end_time) + + if first_response_time: + return first_response_time + else: + return 1.0 + +def get_time_in_seconds(date): + return timedelta(hours=date.hour, minutes=date.minute, seconds=date.second) + +def get_working_hours(date, support_hours): + if is_work_day(date, support_hours): + weekday = frappe.utils.get_weekday(date) + for day in support_hours: + if day.workday == weekday: + return day.start_time, day.end_time + +def is_work_day(date, support_hours): + weekday = frappe.utils.get_weekday(date) + for day in support_hours: + if day.workday == weekday: + return True + return False + +def is_during_working_hours(date, support_hours): + start_time, end_time = get_working_hours(date, support_hours) + time = get_time_in_seconds(date) + if time >= start_time and time <= end_time: + return True + return False + +def get_elapsed_time(start_time, end_time): + return round(time_diff_in_seconds(end_time, start_time), 2) + +def calculate_initial_frt(issue_creation_date, days_in_between, support_hours): + initial_frt = 0 + for i in range(days_in_between): + date = issue_creation_date + timedelta(days = (i+1)) + if is_work_day(date, support_hours): + start_time, end_time = get_working_hours(date, support_hours) + initial_frt += get_elapsed_time(start_time, end_time) + + return initial_frt + +def is_before_working_hours(date, support_hours): + start_time, end_time = get_working_hours(date, support_hours) + time = get_time_in_seconds(date) + if time < start_time: + return True + return False + def get_holidays(holiday_list_name): holiday_list = frappe.get_cached_doc("Holiday List", holiday_list_name) holidays = [holiday.holiday_date for holiday in holiday_list.holidays] - return holidays \ No newline at end of file + return holidays diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 7b9b1446d4b..84f8c398bee 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -5,16 +5,18 @@ from __future__ import unicode_literals import frappe import unittest from erpnext.support.doctype.service_level_agreement.test_service_level_agreement import create_service_level_agreements_for_issues -from frappe.utils import now_datetime, get_datetime, flt +from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.utils import get_datetime, flt import datetime from datetime import timedelta -class TestIssue(unittest.TestCase): +class TestSetUp(unittest.TestCase): def setUp(self): frappe.db.sql("delete from `tabService Level Agreement`") frappe.db.set_value("Support Settings", None, "track_service_level_agreement", 1) create_service_level_agreements_for_issues() +class TestIssue(TestSetUp): def test_response_time_and_resolution_time_based_on_different_sla(self): creation = datetime.datetime(2019, 3, 4, 12, 0) @@ -133,6 +135,223 @@ class TestIssue(unittest.TestCase): issue.reload() self.assertEqual(flt(issue.total_hold_time, 2), 2700) +class TestFirstResponseTime(TestSetUp): + # working hours used in all cases: Mon-Fri, 10am to 6pm + # all dates are in the mm-dd-yyyy format + + # issue creation and first response are on the same day + def test_first_response_time_case1(self): + """ + Test frt when issue creation and first response are during working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 11:00"), get_datetime("06-28-2021 12:00")) + self.assertEqual(issue.first_response_time, 3600.0) + + def test_first_response_time_case2(self): + """ + Test frt when issue creation was during working hours, but first response is sent after working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-28-2021 20:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case3(self): + """ + Test frt when issue creation was before working hours but first response is sent during working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-28-2021 12:00")) + self.assertEqual(issue.first_response_time, 7200.0) + + def test_first_response_time_case4(self): + """ + Test frt when both issue creation and first response were after working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 19:00"), get_datetime("06-28-2021 20:00")) + self.assertEqual(issue.first_response_time, 1.0) + + def test_first_response_time_case5(self): + """ + Test frt when both issue creation and first response are on the same day, but it's not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-27-2021 10:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 1.0) + + # issue creation and first response are on consecutive days + def test_first_response_time_case6(self): + """ + Test frt when the issue was created before working hours and the first response is also sent before working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 6:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case7(self): + """ + Test frt when the issue was created before working hours and the first response is sent during working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 11:00")) + self.assertEqual(issue.first_response_time, 32400.0) + + def test_first_response_time_case8(self): + """ + Test frt when the issue was created before working hours and the first response is sent after working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 20:00")) + self.assertEqual(issue.first_response_time, 57600.0) + + def test_first_response_time_case9(self): + """ + Test frt when the issue was created before working hours and the first response is sent on the next day, which is not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 6:00"), get_datetime("06-26-2021 11:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case10(self): + """ + Test frt when the issue was created during working hours and the first response is sent before working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 6:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case11(self): + """ + Test frt when the issue was created during working hours and the first response is also sent during working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 11:00")) + self.assertEqual(issue.first_response_time, 25200.0) + + def test_first_response_time_case12(self): + """ + Test frt when the issue was created during working hours and the first response is sent after working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 20:00")) + self.assertEqual(issue.first_response_time, 50400.0) + + def test_first_response_time_case13(self): + """ + Test frt when the issue was created during working hours and the first response is sent on the next day, which is not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 12:00"), get_datetime("06-26-2021 11:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case14(self): + """ + Test frt when the issue was created after working hours and the first response is sent before working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 6:00")) + self.assertEqual(issue.first_response_time, 1.0) + + def test_first_response_time_case15(self): + """ + Test frt when the issue was created after working hours and the first response is sent during working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 11:00")) + self.assertEqual(issue.first_response_time, 3600.0) + + def test_first_response_time_case16(self): + """ + Test frt when the issue was created after working hours and the first response is also sent after working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 20:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case17(self): + """ + Test frt when the issue was created after working hours and the first response is sent on the next day, which is not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 20:00"), get_datetime("06-26-2021 11:00")) + self.assertEqual(issue.first_response_time, 1.0) + + # issue creation and first response are a few days apart + def test_first_response_time_case18(self): + """ + Test frt when the issue was created before working hours and the first response is also sent before working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 6:00")) + self.assertEqual(issue.first_response_time, 86400.0) + + def test_first_response_time_case19(self): + """ + Test frt when the issue was created before working hours and the first response is sent during working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 11:00")) + self.assertEqual(issue.first_response_time, 90000.0) + + def test_first_response_time_case20(self): + """ + Test frt when the issue was created before working hours and the first response is sent after working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 20:00")) + self.assertEqual(issue.first_response_time, 115200.0) + + def test_first_response_time_case21(self): + """ + Test frt when the issue was created before working hours and the first response is sent after a few days, on a holiday. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 6:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case22(self): + """ + Test frt when the issue was created during working hours and the first response is sent before working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 6:00")) + self.assertEqual(issue.first_response_time, 79200.0) + + def test_first_response_time_case23(self): + """ + Test frt when the issue was created during working hours and the first response is also sent during working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 11:00")) + self.assertEqual(issue.first_response_time, 82800.0) + + def test_first_response_time_case24(self): + """ + Test frt when the issue was created during working hours and the first response is sent after working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 20:00")) + self.assertEqual(issue.first_response_time, 108000.0) + + def test_first_response_time_case25(self): + """ + Test frt when the issue was created during working hours and the first response is sent after a few days, on a holiday. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 12:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case26(self): + """ + Test frt when the issue was created after working hours and the first response is sent before working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 6:00")) + self.assertEqual(issue.first_response_time, 57600.0) + + def test_first_response_time_case27(self): + """ + Test frt when the issue was created after working hours and the first response is sent during working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 11:00")) + self.assertEqual(issue.first_response_time, 61200.0) + + def test_first_response_time_case28(self): + """ + Test frt when the issue was created after working hours and the first response is also sent after working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 20:00")) + self.assertEqual(issue.first_response_time, 86400.0) + + def test_first_response_time_case29(self): + """ + Test frt when the issue was created after working hours and the first response is sent after a few days, on a holiday. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 20:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 1.0) + +def create_issue_and_communication(issue_creation, first_responded_on): + issue = make_issue(issue_creation, index=1) + sender = create_user("test@admin.com") + create_communication(issue.name, sender.email, "Sent", first_responded_on) + issue.reload() + + return issue def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None): issue = frappe.get_doc({ @@ -185,7 +404,7 @@ def create_territory(territory): def create_communication(reference_name, sender, sent_or_received, creation): - issue = frappe.get_doc({ + communication = frappe.get_doc({ "doctype": "Communication", "communication_type": "Communication", "communication_medium": "Email", @@ -199,4 +418,4 @@ def create_communication(reference_name, sender, sent_or_received, creation): "creation": creation, "reference_name": reference_name }) - issue.save() + communication.save() \ No newline at end of file diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js index 308bce48dfb..ae2080c3b53 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js @@ -5,15 +5,15 @@ frappe.ui.form.on('Service Level Agreement', { setup: function(frm) { if (cint(frm.doc.apply_sla_for_resolution) === 1) { frm.get_field('priorities').grid.editable_fields = [ - {fieldname: 'priority', columns: 1}, {fieldname: 'default_priority', columns: 1}, + {fieldname: 'priority', columns: 2}, {fieldname: 'response_time', columns: 2}, {fieldname: 'resolution_time', columns: 2} ]; } else { frm.get_field('priorities').grid.editable_fields = [ - {fieldname: 'priority', columns: 1}, {fieldname: 'default_priority', columns: 1}, + {fieldname: 'priority', columns: 2}, {fieldname: 'response_time', columns: 3}, ]; } diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json index 61ca3a334e4..ef14b298968 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "format:SLA-{document_type}-{service_level}-{####}", + "autoname": "format:SLA-{document_type}-{service_level}", "creation": "2018-12-26 21:08:15.448812", "doctype": "DocType", "editable_grid": 1, @@ -150,7 +150,8 @@ "fieldtype": "Link", "label": "Document Type", "options": "DocType", - "reqd": 1 + "reqd": 1, + "set_only_once": 1 }, { "default": "1", @@ -178,7 +179,7 @@ } ], "links": [], - "modified": "2021-05-29 13:35:41.956849", + "modified": "2021-07-08 12:28:46.283334", "modified_by": "Administrator", "module": "Support", "name": "Service Level Agreement", diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 60e5fbe80ee..8739cb2364c 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -797,7 +797,7 @@ def set_response_by_and_variance(doc, meta, start_date_time, priority): if meta.has_field("response_by"): doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) - if meta.has_field("response_by_variance"): + if meta.has_field("response_by_variance") and not doc.get('first_responded_on'): now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) @@ -805,7 +805,7 @@ def set_resolution_by_and_variance(doc, meta, start_date_time, priority): if meta.has_field("resolution_by"): doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) - if meta.has_field("resolution_by_variance"): + if meta.has_field("resolution_by_variance") and not doc.get("resolution_date"): now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) diff --git a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py index 2a8446d29f9..7bc97d60227 100644 --- a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py @@ -81,10 +81,9 @@ class TestServiceLevelAgreement(unittest.TestCase): # check SLA custom fields created for leads sla_fields = get_service_level_agreement_fields() - meta = frappe.get_meta(doctype, cached=False) for field in sla_fields: - self.assertTrue(meta.has_field(field.get("fieldname"))) + self.assertTrue(frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": field.get("fieldname")})) def test_docfield_creation_for_sla_on_custom_dt(self): doctype = create_custom_doctype() @@ -102,10 +101,9 @@ class TestServiceLevelAgreement(unittest.TestCase): # check SLA docfields created sla_fields = get_service_level_agreement_fields() - meta = frappe.get_meta(doctype.name, cached=False) for field in sla_fields: - self.assertTrue(meta.has_field(field.get("fieldname"))) + self.assertTrue(frappe.db.exists("DocField", {"fieldname": field.get("fieldname"), "parent": doctype.name})) def test_sla_application(self): # Default Service Level Agreement @@ -219,6 +217,42 @@ class TestServiceLevelAgreement(unittest.TestCase): lead.reload() self.assertEqual(lead.agreement_status, 'Fulfilled') + def test_changing_of_variance_after_response(self): + # create lead + doctype = "Lead" + lead_sla = create_service_level_agreement( + default_service_level_agreement=1, + holiday_list="__Test Holiday List", + entity_type=None, entity=None, + response_time=14400, + doctype=doctype, + sla_fulfilled_on=[{"status": "Replied"}], + apply_sla_for_resolution=0 + ) + creation = datetime.datetime(2019, 3, 4, 12, 0) + lead = make_lead(creation=creation, index=2) + self.assertEqual(lead.service_level_agreement, lead_sla.name) + + # set lead as replied to set first responded on + frappe.flags.current_time = datetime.datetime(2019, 3, 4, 15, 30) + lead.reload() + lead.status = 'Replied' + lead.save() + lead.reload() + self.assertEqual(lead.agreement_status, 'Fulfilled') + + # check response_by_variance + self.assertEqual(lead.first_responded_on, frappe.flags.current_time) + self.assertEqual(lead.response_by_variance, 1800.0) + + # make a change on the document & + # check response_by_variance is unchanged + frappe.flags.current_time = datetime.datetime(2019, 3, 4, 18, 30) + lead.status = 'Open' + lead.save() + lead.reload() + self.assertEqual(lead.response_by_variance, 1800.0) + def tearDown(self): for d in frappe.get_all("Service Level Agreement"): frappe.delete_doc("Service Level Agreement", d.name, force=1) @@ -251,7 +285,7 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list "doctype": "Service Level Agreement", "enabled": 1, "document_type": doctype, - "service_level": "__Test Service Level", + "service_level": "__Test {} SLA".format(entity_type if entity_type else "Default"), "default_service_level_agreement": default_service_level_agreement, "default_priority": "Medium", "holiday_list": holiday_list, @@ -305,16 +339,6 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list "workday": "Friday", "start_time": "10:00:00", "end_time": "18:00:00", - }, - { - "workday": "Saturday", - "start_time": "10:00:00", - "end_time": "18:00:00", - }, - { - "workday": "Sunday", - "start_time": "10:00:00", - "end_time": "18:00:00", } ] }) @@ -330,16 +354,11 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list "entity": entity }) - service_level_agreement_exists = frappe.db.exists("Service Level Agreement", filters) + sla = frappe.db.exists("Service Level Agreement", filters) + if sla: + frappe.delete_doc("Service Level Agreement", sla, force=1) - if not service_level_agreement_exists: - doc = frappe.get_doc(service_level_agreement).insert(ignore_permissions=True) - else: - doc = frappe.get_doc("Service Level Agreement", service_level_agreement_exists) - doc.update(service_level_agreement) - doc.save() - - return doc + return frappe.get_doc(service_level_agreement).insert(ignore_permissions=True) def create_customer(): diff --git a/erpnext/support/doctype/service_level_priority/service_level_priority.json b/erpnext/support/doctype/service_level_priority/service_level_priority.json index 0367fc6d887..b410fe66604 100644 --- a/erpnext/support/doctype/service_level_priority/service_level_priority.json +++ b/erpnext/support/doctype/service_level_priority/service_level_priority.json @@ -5,9 +5,9 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "priority", - "cb_01", "default_priority", + "cb_01", + "priority", "sb_00", "response_time", "cb_00", @@ -15,7 +15,7 @@ ], "fields": [ { - "columns": 1, + "columns": 2, "fieldname": "priority", "fieldtype": "Link", "in_list_view": 1, @@ -64,7 +64,7 @@ ], "istable": 1, "links": [], - "modified": "2021-05-29 19:52:51.733248", + "modified": "2021-06-21 12:00:58.089962", "modified_by": "Administrator", "module": "Support", "name": "Service Level Priority", diff --git a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js index 576e0b76dad..18691fe264f 100644 --- a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js +++ b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js @@ -22,10 +22,10 @@ frappe.query_reports["First Response Time for Issues"] = { get_chart_data: function(_columns, result) { return { data: { - labels: result.map(d => d[0]), + labels: result.map(d => d.creation_date), datasets: [{ name: 'First Response Time', - values: result.map(d => d[1]) + values: result.map(d => d.first_response_time) }] }, type: "line", @@ -35,8 +35,7 @@ frappe.query_reports["First Response Time for Issues"] = { hide_days: 0, hide_seconds: 0 }; - value = frappe.utils.get_formatted_duration(d, duration_options); - return value; + return frappe.utils.get_formatted_duration(d, duration_options); } } } diff --git a/erpnext/support/report/issue_summary/issue_summary.js b/erpnext/support/report/issue_summary/issue_summary.js index eb0e06cd08b..a5122d03ad1 100644 --- a/erpnext/support/report/issue_summary/issue_summary.js +++ b/erpnext/support/report/issue_summary/issue_summary.js @@ -42,6 +42,7 @@ frappe.query_reports["Issue Summary"] = { "", {label: __('Open'), value: 'Open'}, {label: __('Replied'), value: 'Replied'}, + {label: __('On Hold'), value: 'On Hold'}, {label: __('Resolved'), value: 'Resolved'}, {label: __('Closed'), value: 'Closed'} ] diff --git a/erpnext/support/report/issue_summary/issue_summary.py b/erpnext/support/report/issue_summary/issue_summary.py index 7861e30d252..bba25b8bed6 100644 --- a/erpnext/support/report/issue_summary/issue_summary.py +++ b/erpnext/support/report/issue_summary/issue_summary.py @@ -62,7 +62,7 @@ class IssueSummary(object): 'width': 200 }) - self.statuses = ['Open', 'Replied', 'Resolved', 'Closed'] + self.statuses = ['Open', 'Replied', 'On Hold', 'Resolved', 'Closed'] for status in self.statuses: self.columns.append({ 'label': _(status), @@ -265,6 +265,7 @@ class IssueSummary(object): labels = [] open_issues = [] replied_issues = [] + on_hold_issues = [] resolved_issues = [] closed_issues = [] @@ -277,6 +278,7 @@ class IssueSummary(object): labels.append(entry.get(entity_field)) open_issues.append(entry.get('open')) replied_issues.append(entry.get('replied')) + on_hold_issues.append(entry.get('on_hold')) resolved_issues.append(entry.get('resolved')) closed_issues.append(entry.get('closed')) @@ -292,6 +294,10 @@ class IssueSummary(object): 'name': 'Replied', 'values': replied_issues[:30] }, + { + 'name': 'On Hold', + 'values': on_hold_issues[:30] + }, { 'name': 'Resolved', 'values': resolved_issues[:30] @@ -313,12 +319,14 @@ class IssueSummary(object): open_issues = 0 replied = 0 + on_hold = 0 resolved = 0 closed = 0 for entry in self.data: open_issues += entry.get('open') replied += entry.get('replied') + on_hold += entry.get('on_hold') resolved += entry.get('resolved') closed += entry.get('closed') @@ -335,6 +343,12 @@ class IssueSummary(object): 'label': _('Replied'), 'datatype': 'Int', }, + { + 'value': on_hold, + 'indicator': 'Grey', + 'label': _('On Hold'), + 'datatype': 'Int', + }, { 'value': resolved, 'indicator': 'Green', diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py index 4d553df08b8..c00dfa90566 100644 --- a/erpnext/telephony/doctype/call_log/call_log.py +++ b/erpnext/telephony/doctype/call_log/call_log.py @@ -142,7 +142,7 @@ def link_existing_conversations(doc, state): for log in logs: call_log = frappe.get_doc('Call Log', log) call_log.add_link(link_type=doc.doctype, link_name=doc.name) - call_log.save() + call_log.save(ignore_permissions=True) frappe.db.commit() except Exception: frappe.log_error(title=_('Error during caller information update')) diff --git a/erpnext/templates/generators/item_group.html b/erpnext/templates/generators/item_group.html index 393c3a43afb..9050cc388ae 100644 --- a/erpnext/templates/generators/item_group.html +++ b/erpnext/templates/generators/item_group.html @@ -9,7 +9,7 @@ {% endblock %} {% block page_content %} -
    +
    {% if slideshow %} {{ web_block( @@ -127,15 +127,36 @@
    -
    -
    +
    +
    +
    +
    {% if frappe.form_dict.start|int > 0 %} - + {% endif %} {% if items|length >= page_length %} - + {% endif %}
    + + {% endblock %} \ No newline at end of file diff --git a/erpnext/templates/includes/cart/cart_address.html b/erpnext/templates/includes/cart/cart_address.html index 84a9430956e..4482bc10cf7 100644 --- a/erpnext/templates/includes/cart/cart_address.html +++ b/erpnext/templates/includes/cart/cart_address.html @@ -99,6 +99,7 @@ frappe.ready(() => { fieldname: 'country', fieldtype: 'Link', options: 'Country', + only_select: true, reqd: 1 }, { diff --git a/erpnext/templates/includes/projects/project_row.html b/erpnext/templates/includes/projects/project_row.html index 4c8c40db003..a256fbd677b 100644 --- a/erpnext/templates/includes/projects/project_row.html +++ b/erpnext/templates/includes/projects/project_row.html @@ -1,28 +1,54 @@ -{% if doc.status=="Open" %} - +{% if doc.status == "Open" %} +
    +
    +
    + Link + {{ doc.name }} +
    +
    + {{ doc.project_name }} +
    +
    + {% if doc.percent_complete %} + {% set pill_class = "green" if doc.percent_complete | round == 100 else + "orange" %} +
    + + {{ frappe.utils.cint(doc.percent_complete) }} + % + +
    + {% else %} + + {{ doc.status }} + {% endif %} +
    + {% if doc["_assign"] %} + {% set assigned_users = json.loads(doc["_assign"])%} +
    + {% for user in assigned_users %} + {% set user_details = frappe + .db + .get_value("User", user, [ + "full_name", "user_image" + ], as_dict = True) %} + {% if user_details.user_image %} + + + + {% else %} + +
    + {{ frappe.utils.get_abbr(user_details.full_name) }} +
    +
    + {% endif %} + {% endfor %} +
    + {% endif %} +
    + {{ frappe.utils.pretty_date(doc.modified) }} +
    +
    +
    {% endif %} diff --git a/erpnext/templates/includes/projects/project_tasks.html b/erpnext/templates/includes/projects/project_tasks.html index 50b9f4b2597..2b07a5f0d07 100644 --- a/erpnext/templates/includes/projects/project_tasks.html +++ b/erpnext/templates/includes/projects/project_tasks.html @@ -1,32 +1,5 @@ {% for task in doc.tasks %} - +
    + {{ task_row(task, 0) }} +
    {% endfor %} diff --git a/erpnext/templates/includes/projects/project_timesheets.html b/erpnext/templates/includes/projects/project_timesheets.html index 05a07c12e8b..b8e06829192 100644 --- a/erpnext/templates/includes/projects/project_timesheets.html +++ b/erpnext/templates/includes/projects/project_timesheets.html @@ -1,23 +1,33 @@ {% for timesheet in doc.timesheets %} - -{% endfor %} \ No newline at end of file +
    +
    +
    {{ timesheet.name }}
    + Link +
    {{ timesheet.status }}
    +
    {{ frappe.utils.format_date(timesheet.from_time, "medium") }}
    +
    {{ frappe.utils.format_date(timesheet.to_time, "medium") }}
    +
    + {% set user_details = frappe + .db + .get_value("User", timesheet.modified_by, [ + "full_name", "user_image" + ], as_dict = True) + %} + {% if user_details.user_image %} + + + + {% else %} + +
    + {{ frappe.utils.get_abbr(user_details.full_name) }} +
    +
    + {% endif %} +
    +
    + {{ frappe.utils.pretty_date(timesheet.modified) }} +
    +
    +
    +{% endfor %} diff --git a/erpnext/templates/includes/transaction_row.html b/erpnext/templates/includes/transaction_row.html index 383413103e1..3cfb8d8440c 100644 --- a/erpnext/templates/includes/transaction_row.html +++ b/erpnext/templates/includes/transaction_row.html @@ -13,9 +13,11 @@ {{ doc.items_preview }}
    -
    - {{ doc.get_formatted("grand_total") }} -
    + {% if doc.get('grand_total') %} +
    + {{ doc.get_formatted("grand_total") }} +
    + {% endif %}
    Link
    diff --git a/erpnext/templates/pages/partners.py b/erpnext/templates/pages/partners.py index 6725a3e2948..a7e60e26168 100644 --- a/erpnext/templates/pages/partners.py +++ b/erpnext/templates/pages/partners.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import frappe -import frappe.website.render page_title = "Partners" diff --git a/erpnext/templates/pages/projects.html b/erpnext/templates/pages/projects.html index 7e294e076b3..76eaf75cf30 100644 --- a/erpnext/templates/pages/projects.html +++ b/erpnext/templates/pages/projects.html @@ -1,90 +1,173 @@ {% extends "templates/web.html" %} -{% block title %}{{ doc.project_name }}{% endblock %} +{% block title %} + {{ doc.project_name }} +{% endblock %} + +{% block head_include %} + +{% endblock %} {% block header %} -

    {{ doc.project_name }}

    +

    {{ doc.project_name }}

    {% endblock %} {% block style %} - + {% endblock %} - {% block page_content %} -{% if doc.percent_complete %} -
    -
    -
    -
    -{% endif %} -
    -

    {{ _("Tasks") }}

    - {{ _("New task") }} -
    + {{ progress_bar(doc.percent_complete) }} -

    - -

    +
    +

    Status:

    +

    Progress: + {{ doc.percent_complete }} + % +

    +

    Hours Spent: + {{ doc.actual_time }} +

    +
    -{% if doc.tasks %} -
    -
    - {% include "erpnext/templates/includes/projects/project_tasks.html" %} -
    -

    -

    -{% else %} -

    {{ _("No tasks") }}

    -{% endif %} + {{ progress_bar(doc.percent_complete) }} + {% if doc.tasks %} +
    +
    +
    +
    +

    Tasks

    +

    Status

    +

    End Date

    +

    Assigned To

    + +
    +
    + {% include "erpnext/templates/includes/projects/project_tasks.html" %} +
    +
    + {% else %} +

    {{ _("No Tasks") }}

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

    Timesheets

    +

    Status

    +

    From

    +

    To

    +

    Modified By

    +

    Modified On

    +
    +
    + {% include "erpnext/templates/includes/projects/project_timesheets.html" %} +
    +
    + {% else %} +

    {{ _("No Timesheets") }}

    + {% endif %} -

    {{ _("Timesheets") }}

    + {% if doc.attachments %} +
    -{% if doc.timesheets %} -
    - {% include "erpnext/templates/includes/projects/project_timesheets.html" %} -
    - {% if doc.timesheets|length > 9 %} -

    {{ _("More") }}

    - {% endif %} -{% else %} -

    {{ _("No time sheets") }}

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

    {{ _("Attachments") }}

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

    {{ _("Attachments") }}

    +
    + {% for attachment in doc.attachments %} + + {% endfor %} +
    + {% endif %}
    {% endblock %} + +{% macro progress_bar(percent_complete) %} +{% if percent_complete %} +
    +
    +
    +{% else %} +
    +{% endif %} +{% endmacro %} + +{% macro task_row(task, indent) %} +
    + +
    {{ task.status }}
    +
    + {% if task.exp_end_date %} + {{ task.exp_end_date }} + {% else %} + -- + {% endif %} +
    +
    + {% if task["_assign"] %} + {% set assigned_users = json.loads(task["_assign"])%} + {% for user in assigned_users %} + {% set user_details = frappe.db.get_value("User", user, + ["full_name", "user_image"], + as_dict = True)%} + {% if user_details.user_image %} + + + + {% else %} + +
    + {{ frappe.utils.get_abbr(user_details.full_name) }} +
    +
    + {% endif %} + {% endfor %} + {% endif %} +
    +
    + {{ frappe.utils.pretty_date(task.modified) }} +
    +
    +{% if task.children %} + {% for child in task.children %} + {{ task_row(child, indent + 30) }} + {% endfor %} +{% endif %} +{% endmacro %} diff --git a/erpnext/templates/pages/projects.py b/erpnext/templates/pages/projects.py index d23fed9e7d1..7ff495402a4 100644 --- a/erpnext/templates/pages/projects.py +++ b/erpnext/templates/pages/projects.py @@ -32,29 +32,17 @@ def get_tasks(project, start=0, search=None, item_status=None): filters = {"project": project} if search: filters["subject"] = ("like", "%{0}%".format(search)) - # if item_status: -# filters["status"] = item_status tasks = frappe.get_all("Task", filters=filters, - fields=["name", "subject", "status", "_seen", "_comments", "modified", "description"], + fields=["name", "subject", "status", "modified", "_assign", "exp_end_date", "is_group", "parent_task"], limit_start=start, limit_page_length=10) - + task_nest = [] for task in tasks: - task.todo = frappe.get_all('ToDo',filters={'reference_name':task.name, 'reference_type':'Task'}, - fields=["assigned_by", "owner", "modified", "modified_by"]) - - if task.todo: - task.todo=task.todo[0] - task.todo.user_image = frappe.db.get_value('User', task.todo.owner, 'user_image') - - - task.comment_count = len(json.loads(task._comments or "[]")) - - task.css_seen = '' - if task._seen: - if frappe.session.user in json.loads(task._seen): - task.css_seen = 'seen' - - return tasks + if task.is_group: + child_tasks = list(filter(lambda x: x.parent_task == task.name, tasks)) + if len(child_tasks): + task.children = child_tasks + task_nest.append(task) + return list(filter(lambda x: not x.parent_task, tasks)) @frappe.whitelist() def get_task_html(project, start=0, item_status=None): @@ -74,19 +62,11 @@ def get_timesheets(project, start=0, search=None): fields=['project','activity_type','from_time','to_time','parent'], limit_start=start, limit_page_length=10) for timesheet in timesheets: - timesheet.infos = frappe.get_all('Timesheet', filters={"name": timesheet.parent}, - fields=['name','_comments','_seen','status','modified','modified_by'], + info = frappe.get_all('Timesheet', filters={"name": timesheet.parent}, + fields=['name','status','modified','modified_by'], limit_start=start, limit_page_length=10) - - for timesheet.info in timesheet.infos: - timesheet.info.user_image = frappe.db.get_value('User', timesheet.info.modified_by, 'user_image') - - timesheet.info.comment_count = len(json.loads(timesheet.info._comments or "[]")) - - timesheet.info.css_seen = '' - if timesheet.info._seen: - if frappe.session.user in json.loads(timesheet.info._seen): - timesheet.info.css_seen = 'seen' + if len(info): + timesheet.update(info[0]) return timesheets @frappe.whitelist() diff --git a/erpnext/tests/test_subcontracting.py b/erpnext/tests/test_subcontracting.py new file mode 100644 index 00000000000..8b0ce0957d4 --- /dev/null +++ b/erpnext/tests/test_subcontracting.py @@ -0,0 +1,877 @@ +from __future__ import unicode_literals +import frappe +import unittest +import copy +from frappe.utils import cint +from collections import defaultdict +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.buying.doctype.purchase_order.purchase_order import (make_rm_stock_entry, + make_purchase_receipt, make_purchase_invoice, get_materials_from_supplier) + +class TestSubcontracting(unittest.TestCase): + def setUp(self): + make_subcontract_items() + make_raw_materials() + make_bom_for_subcontracted_items() + + def test_po_with_bom(self): + ''' + - Set backflush based on BOM + - Create subcontracted PO for the item Subcontracted Item SA1 and add same item two times. + - Transfer the components from Stores to Supplier warehouse with batch no and serial nos. + - Create purchase receipt against the PO and check serial nos and batch no. + ''' + + set_backflush_based_on('BOM') + item_code = 'Subcontracted Item SA1' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 5, 'rate': 100}, + {'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 6, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 1', 'qty': 5}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 5}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 5}, + {'item_code': 'Subcontracted SRM Item 1', 'qty': 6}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 6}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 6} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name if d.get('qty') == 5 else po.items[1].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + transferred_detais = itemwise_details.get(key) + + for field in ['qty', 'serial_no', 'batch_no']: + if value.get(field): + transfer, consumed = (transferred_detais.get(field), value.get(field)) + if field == 'serial_no': + transfer, consumed = (sorted(transfer), sorted(consumed)) + + self.assertEqual(transfer, consumed) + + def test_po_with_material_transfer(self): + ''' + - Set backflush based on Material Transfer + - Create subcontracted PO for the item Subcontracted Item SA1 and Subcontracted Item SA5. + - Transfer the components from Stores to Supplier warehouse with batch no and serial nos. + - Transfer extra item Subcontracted SRM Item 4 for the subcontract item Subcontracted Item SA5. + - Create partial purchase receipt against the PO and check serial nos and batch no. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': 'Subcontracted Item SA1', 'qty': 5, 'rate': 100}, + {'warehouse': '_Test Warehouse - _TC', 'item_code': 'Subcontracted Item SA5', 'qty': 6, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 1', 'qty': 5, 'main_item_code': 'Subcontracted Item SA1'}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 5, 'main_item_code': 'Subcontracted Item SA1'}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 5, 'main_item_code': 'Subcontracted Item SA1'}, + {'item_code': 'Subcontracted SRM Item 5', 'qty': 6, 'main_item_code': 'Subcontracted Item SA5'}, + {'item_code': 'Subcontracted SRM Item 4', 'qty': 6, 'main_item_code': 'Subcontracted Item SA5'} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name if d.get('qty') == 5 else po.items[1].name + + make_stock_transfer_entry(po_no = po.name, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.remove(pr1.items[1]) + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + transferred_detais = itemwise_details.get(key) + + for field in ['qty', 'serial_no', 'batch_no']: + if value.get(field): + self.assertEqual(value.get(field), transferred_detais.get(field)) + + pr2 = make_purchase_receipt(po.name) + pr2.submit() + + for key, value in get_supplied_items(pr2).items(): + transferred_detais = itemwise_details.get(key) + + for field in ['qty', 'serial_no', 'batch_no']: + if value.get(field): + self.assertEqual(value.get(field), transferred_detais.get(field)) + + def test_subcontract_with_same_components_different_fg(self): + ''' + - Set backflush based on Material Transfer + - Create subcontracted PO for the item Subcontracted Item SA2 and Subcontracted Item SA3. + - Transfer the components from Stores to Supplier warehouse with serial nos. + - Transfer extra qty of components for the item Subcontracted Item SA2. + - Create partial purchase receipt against the PO and check serial nos and batch no. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': 'Subcontracted Item SA2', 'qty': 5, 'rate': 100}, + {'warehouse': '_Test Warehouse - _TC', 'item_code': 'Subcontracted Item SA3', 'qty': 6, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 2', 'qty': 6, 'main_item_code': 'Subcontracted Item SA2'}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 6, 'main_item_code': 'Subcontracted Item SA3'} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name if d.get('qty') == 5 else po.items[1].name + + make_stock_transfer_entry(po_no = po.name, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 3 + pr1.remove(pr1.items[1]) + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + transferred_detais = itemwise_details.get(key) + self.assertEqual(value.qty, 4) + self.assertEqual(sorted(value.serial_no), sorted(transferred_detais.get('serial_no')[0:4])) + + pr2 = make_purchase_receipt(po.name) + pr2.items[0].qty = 2 + pr2.remove(pr2.items[1]) + pr2.submit() + + for key, value in get_supplied_items(pr2).items(): + transferred_detais = itemwise_details.get(key) + + self.assertEqual(value.qty, 2) + self.assertEqual(sorted(value.serial_no), sorted(transferred_detais.get('serial_no')[4:6])) + + pr3 = make_purchase_receipt(po.name) + pr3.submit() + for key, value in get_supplied_items(pr3).items(): + transferred_detais = itemwise_details.get(key) + + self.assertEqual(value.qty, 6) + self.assertEqual(sorted(value.serial_no), sorted(transferred_detais.get('serial_no')[6:12])) + + def test_return_non_consumed_materials(self): + ''' + - Set backflush based on Material Transfer + - Create subcontracted PO for the item Subcontracted Item SA2. + - Transfer the components from Stores to Supplier warehouse with serial nos. + - Transfer extra qty of component for the subcontracted item Subcontracted Item SA2. + - Create purchase receipt for full qty against the PO and change the qty of raw material. + - After that return the non consumed material back to the store from supplier's warehouse. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': 'Subcontracted Item SA2', 'qty': 5, 'rate': 100}] + rm_items = [{'item_code': 'Subcontracted SRM Item 2', 'qty': 6, 'main_item_code': 'Subcontracted Item SA2'}] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.save() + pr1.supplied_items[0].consumed_qty = 5 + pr1.supplied_items[0].serial_no = '\n'.join(sorted( + itemwise_details.get('Subcontracted SRM Item 2').get('serial_no')[0:5] + )) + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + transferred_detais = itemwise_details.get(key) + self.assertEqual(value.qty, 5) + self.assertEqual(sorted(value.serial_no), sorted(transferred_detais.get('serial_no')[0:5])) + + po.load_from_db() + self.assertEqual(po.supplied_items[0].consumed_qty, 5) + doc = get_materials_from_supplier(po.name, [d.name for d in po.supplied_items]) + self.assertEqual(doc.items[0].qty, 1) + self.assertEqual(doc.items[0].s_warehouse, '_Test Warehouse 1 - _TC') + self.assertEqual(doc.items[0].t_warehouse, '_Test Warehouse - _TC') + self.assertEqual(get_serial_nos(doc.items[0].serial_no), + itemwise_details.get(doc.items[0].item_code)['serial_no'][5:6]) + + def test_item_with_batch_based_on_bom(self): + ''' + - Set backflush based on BOM + - Create subcontracted PO for the item Subcontracted Item SA4 (has batch no). + - Transfer the components from Stores to Supplier warehouse with batch no and serial nos. + - Transfer the components in multiple batches. + - Create the 3 purchase receipt against the PO and split Subcontracted Items into two batches. + - Keep the qty as 2 for Subcontracted Item in the purchase receipt. + ''' + + set_backflush_based_on('BOM') + item_code = 'Subcontracted Item SA4' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 1', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 1} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 2 + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 4) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 2 + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 4) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 2 + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 2) + + def test_item_with_batch_based_on_material_transfer(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA4 (has batch no). + - Transfer the components from Stores to Supplier warehouse with batch no and serial nos. + - Transfer the components in multiple batches with extra 2 qty for the batched item. + - Create the 3 purchase receipt against the PO and split Subcontracted Items into two batches. + - Keep the qty as 2 for Subcontracted Item in the purchase receipt. + - In the first purchase receipt the batched raw materials will be consumed 2 extra qty. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA4' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 1', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 2 + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + qty = 4 if key != 'Subcontracted SRM Item 3' else 6 + self.assertEqual(value.qty, qty) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 2 + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 4) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 2 + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 2) + + def test_partial_transfer_serial_no_components_based_on_material_transfer(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA2. + - Transfer the partial components from Stores to Supplier warehouse with serial nos. + - Create partial purchase receipt against the PO and change the qty manually. + - Transfer the remaining components from Stores to Supplier warehouse with serial nos. + - Create purchase receipt for remaining qty against the PO and change the qty manually. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA2' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 2', 'qty': 5}] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 5 + pr1.save() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, 3) + self.assertEqual(sorted(value.serial_no), sorted(details.serial_no[0:3])) + + pr1.load_from_db() + pr1.supplied_items[0].consumed_qty = 5 + pr1.supplied_items[0].serial_no = '\n'.join(itemwise_details[pr1.supplied_items[0].rm_item_code]['serial_no']) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(sorted(value.serial_no), sorted(details.serial_no)) + + itemwise_details = make_stock_in_entry(rm_items=rm_items) + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(sorted(value.serial_no), sorted(details.serial_no)) + + def test_incorrect_serial_no_components_based_on_material_transfer(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA2. + - Transfer the serialized componenets to the supplier. + - Create purchase receipt and change the serial no which is not transferred. + - System should throw the error and not allowed to save the purchase receipt. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA2' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 2', 'qty': 10}] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.save() + pr1.supplied_items[0].serial_no = 'ABCD' + self.assertRaises(frappe.ValidationError, pr1.save) + pr1.delete() + + def test_partial_transfer_batch_based_on_material_transfer(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA6. + - Transfer the partial components from Stores to Supplier warehouse with batch. + - Create partial purchase receipt against the PO and change the qty manually. + - Transfer the remaining components from Stores to Supplier warehouse with batch. + - Create purchase receipt for remaining qty against the PO and change the qty manually. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA6' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 3', 'qty': 5}] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 5 + pr1.save() + + transferred_batch_no = '' + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, 3) + transferred_batch_no = details.batch_no + self.assertEqual(value.batch_no, details.batch_no) + + pr1.load_from_db() + pr1.supplied_items[0].consumed_qty = 5 + pr1.supplied_items[0].batch_no = list(transferred_batch_no.keys())[0] + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(value.batch_no, details.batch_no) + + itemwise_details = make_stock_in_entry(rm_items=rm_items) + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_receipt(po.name) + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(value.batch_no, details.batch_no) + + + def test_item_with_batch_based_on_material_transfer_for_purchase_invoice(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA4 (has batch no). + - Transfer the components from Stores to Supplier warehouse with batch no and serial nos. + - Transfer the components in multiple batches with extra 2 qty for the batched item. + - Create the 3 purchase receipt against the PO and split Subcontracted Items into two batches. + - Keep the qty as 2 for Subcontracted Item in the purchase receipt. + - In the first purchase receipt the batched raw materials will be consumed 2 extra qty. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA4' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 1', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 2 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + qty = 4 if key != 'Subcontracted SRM Item 3' else 6 + self.assertEqual(value.qty, qty) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.items[0].qty = 2 + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 4) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 2 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 2) + + def test_partial_transfer_serial_no_components_based_on_material_transfer_for_purchase_invoice(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA2. + - Transfer the partial components from Stores to Supplier warehouse with serial nos. + - Create partial purchase receipt against the PO and change the qty manually. + - Transfer the remaining components from Stores to Supplier warehouse with serial nos. + - Create purchase receipt for remaining qty against the PO and change the qty manually. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA2' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 2', 'qty': 5}] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 5 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.save() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, 3) + self.assertEqual(sorted(value.serial_no), sorted(details.serial_no[0:3])) + + pr1.load_from_db() + pr1.supplied_items[0].consumed_qty = 5 + pr1.supplied_items[0].serial_no = '\n'.join(itemwise_details[pr1.supplied_items[0].rm_item_code]['serial_no']) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(sorted(value.serial_no), sorted(details.serial_no)) + + itemwise_details = make_stock_in_entry(rm_items=rm_items) + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(sorted(value.serial_no), sorted(details.serial_no)) + + def test_partial_transfer_batch_based_on_material_transfer_for_purchase_invoice(self): + ''' + - Set backflush based on Material Transferred for Subcontract + - Create subcontracted PO for the item Subcontracted Item SA6. + - Transfer the partial components from Stores to Supplier warehouse with batch. + - Create partial purchase receipt against the PO and change the qty manually. + - Transfer the remaining components from Stores to Supplier warehouse with batch. + - Create purchase receipt for remaining qty against the PO and change the qty manually. + ''' + + set_backflush_based_on('Material Transferred for Subcontract') + item_code = 'Subcontracted Item SA6' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 3', 'qty': 5}] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 5 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.save() + + transferred_batch_no = '' + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, 3) + transferred_batch_no = details.batch_no + self.assertEqual(value.batch_no, details.batch_no) + + pr1.load_from_db() + pr1.supplied_items[0].consumed_qty = 5 + pr1.supplied_items[0].batch_no = list(transferred_batch_no.keys())[0] + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(value.batch_no, details.batch_no) + + itemwise_details = make_stock_in_entry(rm_items=rm_items) + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + details = itemwise_details.get(key) + self.assertEqual(value.qty, details.qty) + self.assertEqual(value.batch_no, details.batch_no) + + def test_item_with_batch_based_on_bom_for_purchase_invoice(self): + ''' + - Set backflush based on BOM + - Create subcontracted PO for the item Subcontracted Item SA4 (has batch no). + - Transfer the components from Stores to Supplier warehouse with batch no and serial nos. + - Transfer the components in multiple batches. + - Create the 3 purchase receipt against the PO and split Subcontracted Items into two batches. + - Keep the qty as 2 for Subcontracted Item in the purchase receipt. + ''' + + set_backflush_based_on('BOM') + item_code = 'Subcontracted Item SA4' + items = [{'warehouse': '_Test Warehouse - _TC', 'item_code': item_code, 'qty': 10, 'rate': 100}] + + rm_items = [{'item_code': 'Subcontracted SRM Item 1', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 2', 'qty': 10}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 3}, + {'item_code': 'Subcontracted SRM Item 3', 'qty': 1} + ] + + 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") + + for d in rm_items: + d['po_detail'] = po.items[0].name + + make_stock_transfer_entry(po_no = po.name, main_item_code=item_code, + rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 2 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 4) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 2 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + add_second_row_in_pr(pr1) + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 4) + + pr1 = make_purchase_invoice(po.name) + pr1.update_stock = 1 + pr1.items[0].qty = 2 + pr1.items[0].expense_account = 'Stock Adjustment - _TC' + pr1.save() + pr1.submit() + + for key, value in get_supplied_items(pr1).items(): + self.assertEqual(value.qty, 2) + +def add_second_row_in_pr(pr): + item_dict = {} + for column in ['item_code', 'item_name', 'qty', 'uom', 'warehouse', 'stock_uom', + 'purchase_order', 'purchase_order_item', 'conversion_factor', 'rate', 'expense_account', 'po_detail']: + item_dict[column] = pr.items[0].get(column) + + pr.append('items', item_dict) + pr.set_missing_values() + +def get_supplied_items(pr_doc): + supplied_items = {} + for row in pr_doc.get('supplied_items'): + if row.rm_item_code not in supplied_items: + supplied_items.setdefault(row.rm_item_code, + frappe._dict({'qty': 0, 'serial_no': [], 'batch_no': defaultdict(float)})) + + details = supplied_items[row.rm_item_code] + update_item_details(row, details) + + return supplied_items + +def make_stock_in_entry(**args): + args = frappe._dict(args) + + items = {} + for row in args.rm_items: + row = frappe._dict(row) + + doc = make_stock_entry(target=row.warehouse or '_Test Warehouse - _TC', + item_code=row.item_code, qty=row.qty or 1, basic_rate=row.rate or 100) + + if row.item_code not in items: + items.setdefault(row.item_code, frappe._dict({'qty': 0, 'serial_no': [], 'batch_no': defaultdict(float)})) + + child_row = doc.items[0] + details = items[child_row.item_code] + update_item_details(child_row, details) + + return items + +def update_item_details(child_row, details): + details.qty += (child_row.get('qty') if child_row.doctype == 'Stock Entry Detail' + else child_row.get('consumed_qty')) + + if child_row.serial_no: + details.serial_no.extend(get_serial_nos(child_row.serial_no)) + + if child_row.batch_no: + details.batch_no[child_row.batch_no] += (child_row.get('qty') or child_row.get('consumed_qty')) + +def make_stock_transfer_entry(**args): + args = frappe._dict(args) + + items = [] + for row in args.rm_items: + row = frappe._dict(row) + + item = {'item_code': row.main_item_code or args.main_item_code, 'rm_item_code': row.item_code, + 'qty': row.qty or 1, 'item_name': row.item_code, 'rate': row.rate or 100, + 'stock_uom': row.stock_uom or 'Nos', 'warehouse': row.warehuose or '_Test Warehouse - _TC'} + + item_details = args.itemwise_details.get(row.item_code) + + if item_details and item_details.serial_no: + serial_nos = item_details.serial_no[0:cint(row.qty)] + item['serial_no'] = '\n'.join(serial_nos) + item_details.serial_no = list(set(item_details.serial_no) - set(serial_nos)) + + if item_details and item_details.batch_no: + for batch_no, batch_qty in item_details.batch_no.items(): + if batch_qty >= row.qty: + item['batch_no'] = batch_no + item_details.batch_no[batch_no] -= row.qty + break + + items.append(item) + + ste_dict = make_rm_stock_entry(args.po_no, items) + doc = frappe.get_doc(ste_dict) + doc.insert() + doc.submit() + + return doc + +def make_subcontract_items(): + sub_contracted_items = {'Subcontracted Item SA1': {}, 'Subcontracted Item SA2': {}, 'Subcontracted Item SA3': {}, + 'Subcontracted Item SA4': {'has_batch_no': 1, 'create_new_batch': 1, 'batch_number_series': 'SBAT.####'}, + 'Subcontracted Item SA5': {}, 'Subcontracted Item SA6': {}} + + for item, properties in sub_contracted_items.items(): + if not frappe.db.exists('Item', item): + properties.update({'is_stock_item': 1, 'is_sub_contracted_item': 1}) + make_item(item, properties) + +def make_raw_materials(): + raw_materials = {'Subcontracted SRM Item 1': {}, + 'Subcontracted SRM Item 2': {'has_serial_no': 1, 'serial_no_series': 'SRI.####'}, + 'Subcontracted SRM Item 3': {'has_batch_no': 1, 'create_new_batch': 1, 'batch_number_series': 'BAT.####'}, + 'Subcontracted SRM Item 4': {'has_serial_no': 1, 'serial_no_series': 'SRII.####'}, + 'Subcontracted SRM Item 5': {'has_serial_no': 1, 'serial_no_series': 'SRII.####'}} + + for item, properties in raw_materials.items(): + if not frappe.db.exists('Item', item): + properties.update({'is_stock_item': 1}) + make_item(item, properties) + +def make_bom_for_subcontracted_items(): + boms = { + 'Subcontracted Item SA1': ['Subcontracted SRM Item 1', 'Subcontracted SRM Item 2', 'Subcontracted SRM Item 3'], + 'Subcontracted Item SA2': ['Subcontracted SRM Item 2'], + 'Subcontracted Item SA3': ['Subcontracted SRM Item 2'], + 'Subcontracted Item SA4': ['Subcontracted SRM Item 1', 'Subcontracted SRM Item 2', 'Subcontracted SRM Item 3'], + 'Subcontracted Item SA5': ['Subcontracted SRM Item 5'], + 'Subcontracted Item SA6': ['Subcontracted SRM Item 3'] + } + + for item_code, raw_materials in boms.items(): + if not frappe.db.exists('BOM', {'item': item_code}): + make_bom(item=item_code, raw_materials=raw_materials, rate=100) + +def set_backflush_based_on(based_on): + frappe.db.set_value('Buying Settings', None, + 'backflush_raw_materials_of_subcontract_based_on', based_on) \ No newline at end of file diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py index 16ecd5180b2..11eb6afc1fa 100644 --- a/erpnext/tests/utils.py +++ b/erpnext/tests/utils.py @@ -1,7 +1,8 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals +import copy +from contextlib import contextmanager import frappe @@ -41,3 +42,38 @@ def create_test_contact_and_address(): contact.add_email("test_contact_customer@example.com", is_primary=True) contact.add_phone("+91 0000000000", is_primary_phone=True) contact.insert() + + +@contextmanager +def change_settings(doctype, settings_dict): + """ A context manager to ensure that settings are changed before running + function and restored after running it regardless of exceptions occured. + This is useful in tests where you want to make changes in a function but + don't retain those changes. + import and use as decorator to cover full function or using `with` statement. + + example: + @change_settings("Stock Settings", {"item_naming_by": "Naming Series"}) + def test_case(self): + ... + """ + + try: + settings = frappe.get_doc(doctype) + # remember setting + previous_settings = copy.deepcopy(settings_dict) + for key in previous_settings: + previous_settings[key] = getattr(settings, key) + + # change setting + for key, value in settings_dict.items(): + setattr(settings, key, value) + settings.save() + yield # yield control to calling function + + finally: + # restore settings + settings = frappe.get_doc(doctype) + for key, value in previous_settings.items(): + setattr(settings, key, value) + settings.save() diff --git a/erpnext/www/all-products/index.js b/erpnext/www/all-products/index.js index 0721056816b..1c641b59ad1 100644 --- a/erpnext/www/all-products/index.js +++ b/erpnext/www/all-products/index.js @@ -124,6 +124,10 @@ $(() => { attribute_filters: if_key_exists(attribute_filters) }; + const item_group = $(".item-group-content").data('item-group'); + if (item_group) { + Object.assign(field_filters, { item_group }); + } return new Promise((resolve, reject) => { frappe.call('erpnext.portal.product_configurator.utils.get_products_html_for_website', args) .then(r => { diff --git a/erpnext/www/book_appointment/index.js b/erpnext/www/book_appointment/index.js index 377a3cc0973..5562cbd4710 100644 --- a/erpnext/www/book_appointment/index.js +++ b/erpnext/www/book_appointment/index.js @@ -48,7 +48,7 @@ function setup_date_picker() { function hide_next_button() { let next_button = document.getElementById('next-button'); next_button.disabled = true; - next_button.onclick = () => frappe.msgprint("Please select a date and time"); + next_button.onclick = () => frappe.msgprint(__("Please select a date and time")); } function show_next_button() { @@ -63,7 +63,7 @@ function on_date_or_timezone_select() { if (date_picker.value === '') { clear_time_slots(); hide_next_button(); - frappe.throw('Please select a date'); + frappe.throw(__('Please select a date')); } window.selected_date = date_picker.value; window.selected_timezone = timezone.value; diff --git a/erpnext/www/book_appointment/index.py b/erpnext/www/book_appointment/index.py index 7bfac89f308..4f455614ba1 100644 --- a/erpnext/www/book_appointment/index.py +++ b/erpnext/www/book_appointment/index.py @@ -2,7 +2,7 @@ import frappe import datetime import json import pytz - +from frappe import _ WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] @@ -14,7 +14,8 @@ def get_context(context): if is_enabled: return context else: - frappe.local.flags.redirect_location = '/404' + frappe.redirect_to_message(_("Appointment Scheduling Disabled"), _("Appointment Scheduling has been disabled for this site"), + http_status_code=302, indicator_color="red") raise frappe.Redirect @frappe.whitelist(allow_guest=True) @@ -146,4 +147,4 @@ def _deltatime_to_datetime(date, deltatime): def _datetime_to_deltatime(date_time): midnight = datetime.datetime.combine(date_time.date(), datetime.time.min) - return (date_time-midnight) \ No newline at end of file + return (date_time-midnight)