diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 69749c93aff..f0f83b061b9 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -11,7 +11,7 @@ fi
cd ~ || exit
-sudo apt-get install redis-server libcups2-dev
+sudo apt update && sudo apt install redis-server libcups2-dev
pip install frappe-bench
diff --git a/.github/stale.yml b/.github/stale.yml
index fbf64471567..da15d32680f 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -24,14 +24,4 @@ pulls:
:) Also, even if it is closed, you can always reopen the PR when you're
ready. Thank you for contributing.
-issues:
- daysUntilStale: 90
- daysUntilClose: 7
- exemptLabels:
- - valid
- - to-validate
- - QA
- markComment: >
- This issue has been automatically marked as inactive because it has not had
- recent activity and it wasn't validated by maintainer team. It will be
- closed within a week if no further activity occurs.
+only: pulls
diff --git a/cypress.json b/cypress.json
deleted file mode 100644
index 02b10d893f8..00000000000
--- a/cypress.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "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
deleted file mode 100644
index da18d9352a1..00000000000
--- a/cypress/fixtures/example.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "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_bulk_transaction_processing.js b/cypress/integration/test_bulk_transaction_processing.js
deleted file mode 100644
index 428ec5100b5..00000000000
--- a/cypress/integration/test_bulk_transaction_processing.js
+++ /dev/null
@@ -1,44 +0,0 @@
-describe("Bulk Transaction Processing", () => {
- before(() => {
- cy.login();
- cy.visit("/app/website");
- });
-
- it("Creates To Sales Order", () => {
- cy.visit("/app/sales-order");
- cy.url().should("include", "/sales-order");
- cy.window()
- .its("frappe.csrf_token")
- .then((csrf_token) => {
- return cy
- .request({
- url: "/api/method/erpnext.tests.ui_test_bulk_transaction_processing.create_records",
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- "X-Frappe-CSRF-Token": csrf_token,
- },
- timeout: 60000,
- })
- .then((res) => {
- expect(res.status).eq(200);
- });
- });
- cy.wait(5000);
- cy.get(
- ".list-row-head > .list-header-subject > .list-row-col > .list-check-all"
- ).check({ force: true });
- cy.wait(3000);
- cy.get(".actions-btn-group > .btn-primary").click({ force: true });
- cy.wait(3000);
- cy.get(".dropdown-menu-right > .user-action > .dropdown-item")
- .contains("Sales Invoice")
- .click({ force: true });
- cy.wait(3000);
- cy.get(".modal-content > .modal-footer > .standard-actions")
- .contains("Yes")
- .click({ force: true });
- cy.contains("Creation of Sales Invoice successful");
- });
-});
diff --git a/cypress/integration/test_customer.js b/cypress/integration/test_customer.js
deleted file mode 100644
index 3d6ed5d0d89..00000000000
--- a/cypress/integration/test_customer.js
+++ /dev/null
@@ -1,13 +0,0 @@
-
-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
deleted file mode 100644
index fcb7533a225..00000000000
--- a/cypress/integration/test_item.js
+++ /dev/null
@@ -1,44 +0,0 @@
-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/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js
deleted file mode 100644
index 464cce48d03..00000000000
--- a/cypress/integration/test_organizational_chart_desktop.js
+++ /dev/null
@@ -1,116 +0,0 @@
-context('Organizational Chart', () => {
- before(() => {
- cy.login();
- cy.visit('/app/website');
- });
-
- it('navigates to org chart', () => {
- cy.visit('/app');
- cy.visit('/app/organizational-chart');
- cy.url().should('include', '/organizational-chart');
-
- cy.window().its('frappe.csrf_token').then(csrf_token => {
- return cy.request({
- url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`,
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-Frappe-CSRF-Token': csrf_token
- },
- timeout: 60000
- }).then(res => {
- expect(res.status).eq(200);
- cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
- cy.get('@input')
- .clear({ force: true })
- .type('Test Org Chart{downarrow}{enter}', { force: true })
- .blur({ force: true });
- });
- });
- });
-
- it('renders root nodes and loads children for the first expandable node', () => {
- // check rendered root nodes and the node name, title, connections
- cy.get('.hierarchy').find('.root-level ul.node-children').children()
- .should('have.length', 2)
- .first()
- .as('first-child');
-
- cy.get('@first-child').get('.node-name').contains('Test Employee 1');
- cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO');
- cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2 Connections');
-
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- // children of 1st root visible
- cy.get(`div[data-parent="${employee_records.message[0]}"]`).as('child-node');
- cy.get('@child-node')
- .should('have.length', 1)
- .should('be.visible');
- cy.get('@child-node').get('.node-name').contains('Test Employee 3');
-
- // connectors between first root node and immediate child
- cy.get(`path[data-parent="${employee_records.message[0]}"]`)
- .should('be.visible')
- .invoke('attr', 'data-child')
- .should('equal', employee_records.message[2]);
- });
- });
-
- it('hides active nodes children and connectors on expanding sibling node', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- // click sibling
- cy.get(`#${employee_records.message[1]}`)
- .click()
- .should('have.class', 'active');
-
- // child nodes and connectors hidden
- cy.get(`[data-parent="${employee_records.message[0]}"]`).should('not.be.visible');
- cy.get(`path[data-parent="${employee_records.message[0]}"]`).should('not.be.visible');
- });
- });
-
- it('collapses previous level nodes and refreshes connectors on expanding child node', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- // click child node
- cy.get(`#${employee_records.message[3]}`)
- .click()
- .should('have.class', 'active');
-
- // previous level nodes: parent should be on active-path; other nodes should be collapsed
- cy.get(`#${employee_records.message[0]}`).should('have.class', 'collapsed');
- cy.get(`#${employee_records.message[1]}`).should('have.class', 'active-path');
-
- // previous level connectors refreshed
- cy.get(`path[data-parent="${employee_records.message[1]}"]`)
- .should('have.class', 'collapsed-connector');
-
- // child node's children and connectors rendered
- cy.get(`[data-parent="${employee_records.message[3]}"]`).should('be.visible');
- cy.get(`path[data-parent="${employee_records.message[3]}"]`).should('be.visible');
- });
- });
-
- it('expands previous level nodes', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- cy.get(`#${employee_records.message[0]}`)
- .click()
- .should('have.class', 'active');
-
- cy.get(`[data-parent="${employee_records.message[0]}"]`)
- .should('be.visible');
-
- cy.get('ul.hierarchy').children().should('have.length', 2);
- cy.get(`#connectors`).children().should('have.length', 1);
- });
- });
-
- it('edit node navigates to employee master', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node')
- .click();
-
- cy.url().should('include', `/employee/${employee_records.message[0]}`);
- });
- });
-});
diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js
deleted file mode 100644
index 971ac6d3ef3..00000000000
--- a/cypress/integration/test_organizational_chart_mobile.js
+++ /dev/null
@@ -1,195 +0,0 @@
-context('Organizational Chart Mobile', () => {
- before(() => {
- cy.login();
- cy.visit('/app/website');
- });
-
- it('navigates to org chart', () => {
- cy.viewport(375, 667);
- cy.visit('/app');
- cy.visit('/app/organizational-chart');
- cy.url().should('include', '/organizational-chart');
-
- cy.window().its('frappe.csrf_token').then(csrf_token => {
- return cy.request({
- url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`,
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-Frappe-CSRF-Token': csrf_token
- },
- timeout: 60000
- }).then(res => {
- expect(res.status).eq(200);
- cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
- cy.get('@input')
- .clear({ force: true })
- .type('Test Org Chart{downarrow}{enter}', { force: true })
- .blur({ force: true });
- });
- });
- });
-
- it('renders root nodes', () => {
- // check rendered root nodes and the node name, title, connections
- cy.get('.hierarchy-mobile').find('.root-level').children()
- .should('have.length', 2)
- .first()
- .as('first-child');
-
- cy.get('@first-child').get('.node-name').contains('Test Employee 1');
- cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO');
- cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2');
- });
-
- it('expands root node', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- cy.get(`#${employee_records.message[1]}`)
- .click()
- .should('have.class', 'active');
-
- // other root node removed
- cy.get(`#${employee_records.message[0]}`).should('not.exist');
-
- // children of active root node
- cy.get('.hierarchy-mobile').find('.level').first().find('ul.node-children').children()
- .should('have.length', 2);
-
- cy.get(`div[data-parent="${employee_records.message[1]}"]`).first().as('child-node');
- cy.get('@child-node').should('be.visible');
-
- cy.get('@child-node')
- .get('.node-name')
- .contains('Test Employee 4');
-
- // connectors between root node and immediate children
- cy.get(`path[data-parent="${employee_records.message[1]}"]`).as('connectors');
- cy.get('@connectors')
- .should('have.length', 2)
- .should('be.visible');
-
- cy.get('@connectors')
- .first()
- .invoke('attr', 'data-child')
- .should('eq', employee_records.message[3]);
- });
- });
-
- it('expands child node', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- cy.get(`#${employee_records.message[3]}`)
- .click()
- .should('have.class', 'active')
- .as('expanded_node');
-
- // 2 levels on screen; 1 on active path; 1 collapsed
- cy.get('.hierarchy-mobile').children().should('have.length', 2);
- cy.get(`#${employee_records.message[1]}`).should('have.class', 'active-path');
-
- // children of expanded node visible
- cy.get('@expanded_node')
- .next()
- .should('have.class', 'node-children')
- .as('node-children');
-
- cy.get('@node-children').children().should('have.length', 1);
- cy.get('@node-children')
- .first()
- .get('.node-card')
- .should('have.class', 'active-child')
- .contains('Test Employee 7');
-
- // orphan connectors removed
- cy.get(`#connectors`).children().should('have.length', 2);
- });
- });
-
- it('renders sibling group', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- // sibling group visible for parent
- cy.get(`#${employee_records.message[1]}`)
- .next()
- .as('sibling_group');
-
- cy.get('@sibling_group')
- .should('have.attr', 'data-parent', 'undefined')
- .should('have.class', 'node-group')
- .and('have.class', 'collapsed');
-
- cy.get('@sibling_group').get('.avatar-group').children().as('siblings');
- cy.get('@siblings').should('have.length', 1);
- cy.get('@siblings')
- .first()
- .should('have.attr', 'title', 'Test Employee 1');
-
- });
- });
-
- it('expands previous level nodes', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- cy.get(`#${employee_records.message[6]}`)
- .click()
- .should('have.class', 'active');
-
- // clicking on previous level node should remove all the nodes ahead
- // and expand that node
- cy.get(`#${employee_records.message[3]}`).click();
- cy.get(`#${employee_records.message[3]}`)
- .should('have.class', 'active')
- .should('not.have.class', 'active-path');
-
- cy.get(`#${employee_records.message[6]}`).should('have.class', 'active-child');
- cy.get('.hierarchy-mobile').children().should('have.length', 2);
- cy.get(`#connectors`).children().should('have.length', 2);
- });
- });
-
- it('expands sibling group', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- // sibling group visible for parent
- cy.get(`#${employee_records.message[6]}`).click();
-
- cy.get(`#${employee_records.message[3]}`)
- .next()
- .click();
-
- // siblings of parent should be visible
- cy.get('.hierarchy-mobile').prev().as('sibling_group');
- cy.get('@sibling_group')
- .should('exist')
- .should('have.class', 'sibling-group')
- .should('not.have.class', 'collapsed');
-
- cy.get(`#${employee_records.message[1]}`)
- .should('be.visible')
- .should('have.class', 'active');
-
- cy.get(`[data-parent="${employee_records.message[1]}"]`)
- .should('be.visible')
- .should('have.length', 2)
- .should('have.class', 'active-child');
- });
- });
-
- it('goes to the respective level after clicking on non-collapsed sibling group', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(() => {
- // click on non-collapsed sibling group
- cy.get('.hierarchy-mobile')
- .prev()
- .click();
-
- // should take you to that level
- cy.get('.hierarchy-mobile').find('li.level .node-card').should('have.length', 2);
- });
- });
-
- it('edit node navigates to employee master', () => {
- cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
- cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node')
- .click();
-
- cy.url().should('include', `/employee/${employee_records.message[0]}`);
- });
- });
-});
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
deleted file mode 100644
index 07d9804a733..00000000000
--- a/cypress/plugins/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-// ***********************************************************
-// 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
deleted file mode 100644
index 7ddc80ab8dd..00000000000
--- a/cypress/support/commands.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// ***********************************************
-// 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
deleted file mode 100644
index 72070cc81c4..00000000000
--- a/cypress/support/index.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// ***********************************************************
-// 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
deleted file mode 100644
index d90ebf6856d..00000000000
--- a/cypress/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "compilerOptions": {
- "allowJs": true,
- "baseUrl": "../node_modules",
- "types": [
- "cypress"
- ]
- },
- "include": [
- "**/*.*"
- ]
-}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index d28c3a8687c..145118957bb 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -94,7 +94,7 @@ class JournalEntry(AccountsController):
unlink_ref_doc_from_payment_entries(self)
unlink_ref_doc_from_salary_slip(self.name)
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
+ self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
self.make_gl_entries(1)
self.update_advance_paid()
self.update_expense_claim()
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index a3a7be29585..a10a810d1de 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -95,7 +95,7 @@ class PaymentEntry(AccountsController):
self.set_status()
def on_cancel(self):
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
+ self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
self.make_gl_entries(cancel=1)
self.update_expense_claim()
self.update_outstanding_amounts()
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/__init__.py b/erpnext/accounts/doctype/payment_ledger_entry/__init__.py
similarity index 100%
rename from erpnext/erpnext_integrations/data_migration_mapping/__init__.py
rename to erpnext/accounts/doctype/payment_ledger_entry/__init__.py
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js
new file mode 100644
index 00000000000..5a7be8e5ab2
--- /dev/null
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Payment Ledger Entry', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
new file mode 100644
index 00000000000..39e90420c7d
--- /dev/null
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
@@ -0,0 +1,178 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2022-05-09 19:35:03.334361",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "posting_date",
+ "company",
+ "account_type",
+ "account",
+ "party_type",
+ "party",
+ "due_date",
+ "cost_center",
+ "finance_book",
+ "voucher_type",
+ "voucher_no",
+ "against_voucher_type",
+ "against_voucher_no",
+ "amount",
+ "account_currency",
+ "amount_in_account_currency",
+ "delinked"
+ ],
+ "fields": [
+ {
+ "fieldname": "posting_date",
+ "fieldtype": "Date",
+ "label": "Posting Date"
+ },
+ {
+ "fieldname": "account_type",
+ "fieldtype": "Select",
+ "label": "Account Type",
+ "options": "Receivable\nPayable"
+ },
+ {
+ "fieldname": "account",
+ "fieldtype": "Link",
+ "label": "Account",
+ "options": "Account"
+ },
+ {
+ "fieldname": "party_type",
+ "fieldtype": "Link",
+ "label": "Party Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "party",
+ "fieldtype": "Dynamic Link",
+ "label": "Party",
+ "options": "party_type"
+ },
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Link",
+ "in_standard_filter": 1,
+ "label": "Voucher Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "voucher_no",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Voucher No",
+ "options": "voucher_type"
+ },
+ {
+ "fieldname": "against_voucher_type",
+ "fieldtype": "Link",
+ "in_standard_filter": 1,
+ "label": "Against Voucher Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "against_voucher_no",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Against Voucher No",
+ "options": "against_voucher_type"
+ },
+ {
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "options": "Company:company:default_currency"
+ },
+ {
+ "fieldname": "account_currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency"
+ },
+ {
+ "fieldname": "amount_in_account_currency",
+ "fieldtype": "Currency",
+ "label": "Amount in Account Currency",
+ "options": "account_currency"
+ },
+ {
+ "default": "0",
+ "fieldname": "delinked",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "DeLinked"
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "due_date",
+ "fieldtype": "Date",
+ "label": "Due Date"
+ },
+ {
+ "fieldname": "finance_book",
+ "fieldtype": "Link",
+ "label": "Finance Book",
+ "options": "Finance Book"
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2022-05-30 19:04:55.532171",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Payment Ledger Entry",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Auditor",
+ "share": 1
+ }
+ ],
+ "search_fields": "voucher_no, against_voucher_no",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
new file mode 100644
index 00000000000..43e19f4ae7d
--- /dev/null
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class PaymentLedgerEntry(Document):
+ def validate_account(self):
+ valid_account = frappe.db.get_list(
+ "Account",
+ "name",
+ filters={"name": self.account, "account_type": self.account_type, "company": self.company},
+ ignore_permissions=True,
+ )
+ if not valid_account:
+ frappe.throw(_("{0} account is not of type {1}").format(self.account, self.account_type))
+
+ def validate(self):
+ self.validate_account()
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
new file mode 100644
index 00000000000..a71b19e0922
--- /dev/null
+++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
@@ -0,0 +1,408 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe import qb
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import nowdate
+
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.stock.doctype.item.test_item import create_item
+
+
+class TestPaymentLedgerEntry(FrappeTestCase):
+ def setUp(self):
+ self.ple = qb.DocType("Payment Ledger Entry")
+ self.create_company()
+ self.create_item()
+ self.create_customer()
+ self.clear_old_entries()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def create_company(self):
+ company_name = "_Test Payment Ledger"
+ company = None
+ if frappe.db.exists("Company", company_name):
+ company = frappe.get_doc("Company", company_name)
+ else:
+ company = frappe.get_doc(
+ {
+ "doctype": "Company",
+ "company_name": company_name,
+ "country": "India",
+ "default_currency": "INR",
+ "create_chart_of_accounts_based_on": "Standard Template",
+ "chart_of_accounts": "Standard",
+ }
+ )
+ company = company.save()
+
+ self.company = company.name
+ self.cost_center = company.cost_center
+ self.warehouse = "All Warehouses - _PL"
+ self.income_account = "Sales - _PL"
+ self.expense_account = "Cost of Goods Sold - _PL"
+ self.debit_to = "Debtors - _PL"
+ self.creditors = "Creditors - _PL"
+
+ # create bank account
+ if frappe.db.exists("Account", "HDFC - _PL"):
+ self.bank = "HDFC - _PL"
+ else:
+ bank_acc = frappe.get_doc(
+ {
+ "doctype": "Account",
+ "account_name": "HDFC",
+ "parent_account": "Bank Accounts - _PL",
+ "company": self.company,
+ }
+ )
+ bank_acc.save()
+ self.bank = bank_acc.name
+
+ def create_item(self):
+ item_name = "_Test PL Item"
+ item = create_item(
+ item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse
+ )
+ self.item = item if isinstance(item, str) else item.item_code
+
+ def create_customer(self):
+ name = "_Test PL Customer"
+ if frappe.db.exists("Customer", name):
+ self.customer = name
+ else:
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = name
+ customer.type = "Individual"
+ customer.save()
+ self.customer = customer.name
+
+ def create_sales_invoice(
+ self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ """
+ Helper function to populate default values in sales invoice
+ """
+ sinv = create_sales_invoice(
+ qty=qty,
+ rate=rate,
+ company=self.company,
+ customer=self.customer,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="INR",
+ is_pos=0,
+ is_return=0,
+ return_against=None,
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return sinv
+
+ def create_payment_entry(self, amount=100, posting_date=nowdate()):
+ """
+ Helper function to populate default values in payment entry
+ """
+ payment = create_payment_entry(
+ company=self.company,
+ payment_type="Receive",
+ party_type="Customer",
+ party=self.customer,
+ paid_from=self.debit_to,
+ paid_to=self.bank,
+ paid_amount=amount,
+ )
+ payment.posting_date = posting_date
+ return payment
+
+ def clear_old_entries(self):
+ doctype_list = [
+ "GL Entry",
+ "Payment Ledger Entry",
+ "Sales Invoice",
+ "Purchase Invoice",
+ "Payment Entry",
+ "Journal Entry",
+ ]
+ for doctype in doctype_list:
+ qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
+
+ def create_journal_entry(
+ self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None
+ ):
+ je = frappe.new_doc("Journal Entry")
+ je.posting_date = posting_date or nowdate()
+ je.company = self.company
+ je.user_remark = "test"
+ if not cost_center:
+ cost_center = self.cost_center
+ je.set(
+ "accounts",
+ [
+ {
+ "account": acc1,
+ "cost_center": cost_center,
+ "debit_in_account_currency": amount if amount > 0 else 0,
+ "credit_in_account_currency": abs(amount) if amount < 0 else 0,
+ },
+ {
+ "account": acc2,
+ "cost_center": cost_center,
+ "credit_in_account_currency": amount if amount > 0 else 0,
+ "debit_in_account_currency": abs(amount) if amount < 0 else 0,
+ },
+ ],
+ )
+ return je
+
+ def test_payment_against_invoice(self):
+ transaction_date = nowdate()
+ amount = 100
+ ple = self.ple
+
+ # full payment using PE
+ si1 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+ pe1 = get_payment_entry(si1.doctype, si1.name).save().submit()
+
+ pl_entries = (
+ qb.from_(ple)
+ .select(
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.against_voucher_type,
+ ple.against_voucher_no,
+ ple.amount,
+ ple.delinked,
+ )
+ .where((ple.against_voucher_type == si1.doctype) & (ple.against_voucher_no == si1.name))
+ .orderby(ple.creation)
+ .run(as_dict=True)
+ )
+
+ expected_values = [
+ {
+ "voucher_type": si1.doctype,
+ "voucher_no": si1.name,
+ "against_voucher_type": si1.doctype,
+ "against_voucher_no": si1.name,
+ "amount": amount,
+ "delinked": 0,
+ },
+ {
+ "voucher_type": pe1.doctype,
+ "voucher_no": pe1.name,
+ "against_voucher_type": si1.doctype,
+ "against_voucher_no": si1.name,
+ "amount": -amount,
+ "delinked": 0,
+ },
+ ]
+ self.assertEqual(pl_entries[0], expected_values[0])
+ self.assertEqual(pl_entries[1], expected_values[1])
+
+ def test_partial_payment_against_invoice(self):
+ ple = self.ple
+ transaction_date = nowdate()
+ amount = 100
+
+ # partial payment of invoice using PE
+ si2 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+ pe2 = get_payment_entry(si2.doctype, si2.name)
+ pe2.get("references")[0].allocated_amount = 50
+ pe2.get("references")[0].outstanding_amount = 50
+ pe2 = pe2.save().submit()
+
+ pl_entries = (
+ qb.from_(ple)
+ .select(
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.against_voucher_type,
+ ple.against_voucher_no,
+ ple.amount,
+ ple.delinked,
+ )
+ .where((ple.against_voucher_type == si2.doctype) & (ple.against_voucher_no == si2.name))
+ .orderby(ple.creation)
+ .run(as_dict=True)
+ )
+
+ expected_values = [
+ {
+ "voucher_type": si2.doctype,
+ "voucher_no": si2.name,
+ "against_voucher_type": si2.doctype,
+ "against_voucher_no": si2.name,
+ "amount": amount,
+ "delinked": 0,
+ },
+ {
+ "voucher_type": pe2.doctype,
+ "voucher_no": pe2.name,
+ "against_voucher_type": si2.doctype,
+ "against_voucher_no": si2.name,
+ "amount": -50,
+ "delinked": 0,
+ },
+ ]
+ self.assertEqual(pl_entries[0], expected_values[0])
+ self.assertEqual(pl_entries[1], expected_values[1])
+
+ def test_cr_note_against_invoice(self):
+ ple = self.ple
+ transaction_date = nowdate()
+ amount = 100
+
+ # reconcile against return invoice
+ si3 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+ cr_note1 = self.create_sales_invoice(
+ qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True
+ )
+ cr_note1.is_return = 1
+ cr_note1.return_against = si3.name
+ cr_note1 = cr_note1.save().submit()
+
+ pl_entries = (
+ qb.from_(ple)
+ .select(
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.against_voucher_type,
+ ple.against_voucher_no,
+ ple.amount,
+ ple.delinked,
+ )
+ .where((ple.against_voucher_type == si3.doctype) & (ple.against_voucher_no == si3.name))
+ .orderby(ple.creation)
+ .run(as_dict=True)
+ )
+
+ expected_values = [
+ {
+ "voucher_type": si3.doctype,
+ "voucher_no": si3.name,
+ "against_voucher_type": si3.doctype,
+ "against_voucher_no": si3.name,
+ "amount": amount,
+ "delinked": 0,
+ },
+ {
+ "voucher_type": cr_note1.doctype,
+ "voucher_no": cr_note1.name,
+ "against_voucher_type": si3.doctype,
+ "against_voucher_no": si3.name,
+ "amount": -amount,
+ "delinked": 0,
+ },
+ ]
+ self.assertEqual(pl_entries[0], expected_values[0])
+ self.assertEqual(pl_entries[1], expected_values[1])
+
+ def test_je_against_inv_and_note(self):
+ ple = self.ple
+ transaction_date = nowdate()
+ amount = 100
+
+ # reconcile against return invoice using JE
+ si4 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+ cr_note2 = self.create_sales_invoice(
+ qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True
+ )
+ cr_note2.is_return = 1
+ cr_note2 = cr_note2.save().submit()
+ je1 = self.create_journal_entry(
+ self.debit_to, self.debit_to, amount, posting_date=transaction_date
+ )
+ je1.get("accounts")[0].party_type = je1.get("accounts")[1].party_type = "Customer"
+ je1.get("accounts")[0].party = je1.get("accounts")[1].party = self.customer
+ je1.get("accounts")[0].reference_type = cr_note2.doctype
+ je1.get("accounts")[0].reference_name = cr_note2.name
+ je1.get("accounts")[1].reference_type = si4.doctype
+ je1.get("accounts")[1].reference_name = si4.name
+ je1 = je1.save().submit()
+
+ pl_entries_for_invoice = (
+ qb.from_(ple)
+ .select(
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.against_voucher_type,
+ ple.against_voucher_no,
+ ple.amount,
+ ple.delinked,
+ )
+ .where((ple.against_voucher_type == si4.doctype) & (ple.against_voucher_no == si4.name))
+ .orderby(ple.creation)
+ .run(as_dict=True)
+ )
+
+ expected_values = [
+ {
+ "voucher_type": si4.doctype,
+ "voucher_no": si4.name,
+ "against_voucher_type": si4.doctype,
+ "against_voucher_no": si4.name,
+ "amount": amount,
+ "delinked": 0,
+ },
+ {
+ "voucher_type": je1.doctype,
+ "voucher_no": je1.name,
+ "against_voucher_type": si4.doctype,
+ "against_voucher_no": si4.name,
+ "amount": -amount,
+ "delinked": 0,
+ },
+ ]
+ self.assertEqual(pl_entries_for_invoice[0], expected_values[0])
+ self.assertEqual(pl_entries_for_invoice[1], expected_values[1])
+
+ pl_entries_for_crnote = (
+ qb.from_(ple)
+ .select(
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.against_voucher_type,
+ ple.against_voucher_no,
+ ple.amount,
+ ple.delinked,
+ )
+ .where(
+ (ple.against_voucher_type == cr_note2.doctype) & (ple.against_voucher_no == cr_note2.name)
+ )
+ .orderby(ple.creation)
+ .run(as_dict=True)
+ )
+
+ expected_values = [
+ {
+ "voucher_type": cr_note2.doctype,
+ "voucher_no": cr_note2.name,
+ "against_voucher_type": cr_note2.doctype,
+ "against_voucher_no": cr_note2.name,
+ "amount": -amount,
+ "delinked": 0,
+ },
+ {
+ "voucher_type": je1.doctype,
+ "voucher_no": je1.name,
+ "against_voucher_type": cr_note2.doctype,
+ "against_voucher_no": cr_note2.name,
+ "amount": amount,
+ "delinked": 0,
+ },
+ ]
+ self.assertEqual(pl_entries_for_crnote[0], expected_values[0])
+ self.assertEqual(pl_entries_for_crnote[1], expected_values[1])
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 53b1c64c460..5a86376199c 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -54,8 +54,8 @@ class PeriodClosingVoucher(AccountsController):
pce = frappe.db.sql(
"""select name from `tabPeriod Closing Voucher`
- where posting_date > %s and fiscal_year = %s and docstatus = 1""",
- (self.posting_date, self.fiscal_year),
+ where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""",
+ (self.posting_date, self.fiscal_year, self.company),
)
if pce and pce[0][0]:
frappe.throw(
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 8e0e62d5f8c..3b938ea1ca4 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
@@ -78,6 +78,8 @@ class TestPeriodClosingVoucher(unittest.TestCase):
expense_account="Cost of Goods Sold - TPC",
rate=400,
debit_to="Debtors - TPC",
+ currency="USD",
+ customer="_Test Customer USD",
)
create_sales_invoice(
company=company,
@@ -86,6 +88,8 @@ class TestPeriodClosingVoucher(unittest.TestCase):
expense_account="Cost of Goods Sold - TPC",
rate=200,
debit_to="Debtors - TPC",
+ currency="USD",
+ customer="_Test Customer USD",
)
pcv = self.make_period_closing_voucher(submit=False)
@@ -119,14 +123,17 @@ class TestPeriodClosingVoucher(unittest.TestCase):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")
- create_sales_invoice(
+ si = create_sales_invoice(
company=company,
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
cost_center=cost_center,
rate=400,
debit_to="Debtors - TPC",
+ currency="USD",
+ customer="_Test Customer USD",
)
+
jv = make_journal_entry(
account1="Cash - TPC",
account2="Sales - TPC",
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 572410fc665..98f3420d87e 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
@@ -102,7 +102,9 @@ frappe.ui.form.on('POS Closing Entry', {
});
},
- before_save: function(frm) {
+ before_save: async function(frm) {
+ frappe.dom.freeze(__('Processing Sales! Please Wait...'));
+
frm.set_value("grand_total", 0);
frm.set_value("net_total", 0);
frm.set_value("total_quantity", 0);
@@ -112,17 +114,23 @@ frappe.ui.form.on('POS Closing Entry', {
row.expected_amount = row.opening_amount;
}
- for (let row of frm.doc.pos_transactions) {
- frappe.db.get_doc("POS Invoice", row.pos_invoice).then(doc => {
- frm.doc.grand_total += flt(doc.grand_total);
- frm.doc.net_total += flt(doc.net_total);
- frm.doc.total_quantity += flt(doc.total_qty);
- refresh_payments(doc, frm);
- refresh_taxes(doc, frm);
- refresh_fields(frm);
- set_html_data(frm);
- });
+ const pos_inv_promises = frm.doc.pos_transactions.map(
+ row => frappe.db.get_doc("POS Invoice", row.pos_invoice)
+ );
+
+ const pos_invoices = await Promise.all(pos_inv_promises);
+
+ for (let doc of pos_invoices) {
+ frm.doc.grand_total += flt(doc.grand_total);
+ frm.doc.net_total += flt(doc.net_total);
+ frm.doc.total_quantity += flt(doc.total_qty);
+ refresh_payments(doc, frm);
+ refresh_taxes(doc, frm);
+ refresh_fields(frm);
+ set_html_data(frm);
}
+
+ frappe.dom.unfreeze();
}
});
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 94246e135b6..9649f80dee5 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -96,6 +96,7 @@ class POSInvoice(SalesInvoice):
)
def on_cancel(self):
+ self.ignore_linked_doctypes = "Payment Ledger Entry"
# run on cancel method of selling controller
super(SalesInvoice, self).on_cancel()
if not self.is_return and self.loyalty_program:
diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
index 4cf19b4454e..3bd0cd2e837 100644
--- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
@@ -752,7 +752,7 @@ class TestPricingRule(unittest.TestCase):
title="_Test Pricing Rule with Min Qty - 2",
)
- si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1, currency="USD")
+ si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1)
item = si.items[0]
item.stock_qty = 1
si.save()
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index 42917f811db..7e3597e491e 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -45,8 +45,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
if (this.frm.doc.supplier && this.frm.doc.__islocal) {
this.frm.trigger('supplier');
}
-
- erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
}
refresh(doc) {
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index a1d86e2219a..23ad223e777 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -545,7 +545,16 @@ class PurchaseInvoice(BuyingController):
from_repost=from_repost,
)
elif self.docstatus == 2:
+ provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"]
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
+ if provisional_entries:
+ for entry in provisional_entries:
+ frappe.db.set_value(
+ "GL Entry",
+ {"voucher_type": "Purchase Receipt", "voucher_detail_no": entry.voucher_detail_no},
+ "is_cancelled",
+ 1,
+ )
if update_outstanding == "No":
update_outstanding_amt(
@@ -1127,7 +1136,7 @@ class PurchaseInvoice(BuyingController):
# Stock ledger value is not matching with the warehouse amount
if (
self.update_stock
- and voucher_wise_stock_value.get(item.name)
+ and voucher_wise_stock_value.get((item.name, item.warehouse))
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
@@ -1418,7 +1427,12 @@ class PurchaseInvoice(BuyingController):
frappe.db.set(self, "status", "Cancelled")
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference)
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ self.ignore_linked_doctypes = (
+ "GL Entry",
+ "Stock Ledger Entry",
+ "Repost Item Valuation",
+ "Payment Ledger Entry",
+ )
self.update_advance_tax_references(cancel=1)
def update_project(self):
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 30d26acf3a2..3c70e24cae1 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -27,12 +27,13 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
+from erpnext.stock.tests.test_utils import StockTestMixin
test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"]
test_ignore = ["Serial No"]
-class TestPurchaseInvoice(unittest.TestCase):
+class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
@classmethod
def setUpClass(self):
unlink_payment_on_cancel_of_invoice()
@@ -693,6 +694,80 @@ class TestPurchaseInvoice(unittest.TestCase):
self.assertEqual(expected_values[gle.account][0], gle.debit)
self.assertEqual(expected_values[gle.account][1], gle.credit)
+ def test_standalone_return_using_pi(self):
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
+
+ item = self.make_item().name
+ company = "_Test Company with perpetual inventory"
+ warehouse = "Stores - TCP1"
+
+ make_stock_entry(item_code=item, target=warehouse, qty=50, rate=120)
+
+ return_pi = make_purchase_invoice(
+ is_return=1,
+ item=item,
+ qty=-10,
+ update_stock=1,
+ rate=100,
+ company=company,
+ warehouse=warehouse,
+ cost_center="Main - TCP1",
+ )
+
+ # assert that stock consumption is with actual rate
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 1200, "debit": 0}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
+
+ # assert loss booked in COGS
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 0, "debit": 200}],
+ gle_filters={"account": "Cost of Goods Sold - TCP1"},
+ )
+
+ def test_return_with_lcv(self):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+ from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
+ create_landed_cost_voucher,
+ )
+
+ item = self.make_item().name
+ company = "_Test Company with perpetual inventory"
+ warehouse = "Stores - TCP1"
+ cost_center = "Main - TCP1"
+
+ pi = make_purchase_invoice(
+ item=item,
+ company=company,
+ warehouse=warehouse,
+ cost_center=cost_center,
+ update_stock=1,
+ qty=10,
+ rate=100,
+ )
+
+ # Create landed cost voucher - will increase valuation of received item by 10
+ create_landed_cost_voucher("Purchase Invoice", pi.name, pi.company, charges=100)
+ return_pi = make_return_doc(pi.doctype, pi.name)
+ return_pi.save().submit()
+
+ # assert that stock consumption is with actual in rate
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 1100, "debit": 0}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
+
+ # assert loss booked in COGS
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 0, "debit": 100}],
+ gle_filters={"account": "Cost of Goods Sold - TCP1"},
+ )
+
def test_multi_currency_gle(self):
pi = make_purchase_invoice(
supplier="_Test Supplier USD",
@@ -1526,6 +1601,18 @@ class TestPurchaseInvoice(unittest.TestCase):
check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date)
+ # Cancel purchase invoice to check reverse provisional entry cancellation
+ pi.cancel()
+
+ expected_gle_for_purchase_receipt_post_pi_cancel = [
+ ["Provision Account - _TC", 0, 250, pi.posting_date],
+ ["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date],
+ ]
+
+ check_gl_entries(
+ self, pr.name, expected_gle_for_purchase_receipt_post_pi_cancel, pr.posting_date
+ )
+
company.enable_provisional_accounting_for_non_stock_items = 0
company.save()
diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges_template/purchase_taxes_and_charges_template.json b/erpnext/accounts/doctype/purchase_taxes_and_charges_template/purchase_taxes_and_charges_template.json
index b46d2e32f28..c36efb89a30 100644
--- a/erpnext/accounts/doctype/purchase_taxes_and_charges_template/purchase_taxes_and_charges_template.json
+++ b/erpnext/accounts/doctype/purchase_taxes_and_charges_template/purchase_taxes_and_charges_template.json
@@ -1,10 +1,12 @@
{
+ "actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2013-01-10 16:34:08",
"description": "Standard tax template that can be applied to all Purchase Transactions. This template can contain list of tax heads and also other expense heads like \"Shipping\", \"Insurance\", \"Handling\" etc.\n\n#### Note\n\nThe tax rate you define here will be the standard tax rate for all **Items**. If there are **Items** that have different rates, they must be added in the **Item Tax** table in the **Item** master.\n\n#### Description of Columns\n\n1. Calculation Type: \n - This can be on **Net Total** (that is the sum of basic amount).\n - **On Previous Row Total / Amount** (for cumulative taxes or charges). If you select this option, the tax will be applied as a percentage of the previous row (in the tax table) amount or total.\n - **Actual** (as mentioned).\n2. Account Head: The Account ledger under which this tax will be booked\n3. Cost Center: If the tax / charge is an income (like shipping) or expense it needs to be booked against a Cost Center.\n4. Description: Description of the tax (that will be printed in invoices / quotes).\n5. Rate: Tax rate.\n6. Amount: Tax amount.\n7. Total: Cumulative total to this point.\n8. Enter Row: If based on \"Previous Row Total\" you can select the row number which will be taken as a base for this calculation (default is the previous row).\n9. Consider Tax or Charge for: In this section you can specify if the tax / charge is only for valuation (not a part of total) or only for total (does not add value to the item) or for both.\n10. Add or Deduct: Whether you want to add or deduct the tax.",
"doctype": "DocType",
"document_type": "Setup",
+ "engine": "InnoDB",
"field_order": [
"title",
"is_default",
@@ -74,7 +76,8 @@
],
"icon": "fa fa-money",
"idx": 1,
- "modified": "2019-11-25 13:05:26.220275",
+ "links": [],
+ "modified": "2022-05-16 16:15:29.059370",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges Template",
@@ -103,6 +106,10 @@
"role": "Purchase User"
}
],
+ "show_title_field_in_link": 1,
+ "sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
+ "title_field": "title",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index e30289a62c9..aefa9a59ddf 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -52,7 +52,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
me.frm.refresh_fields();
}
erpnext.queries.setup_warehouse_query(this.frm);
- erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
}
refresh(doc, dt, dn) {
@@ -861,27 +860,44 @@ frappe.ui.form.on('Sales Invoice', {
set_timesheet_data: function(frm, timesheets) {
frm.clear_table("timesheets")
- timesheets.forEach(timesheet => {
+ timesheets.forEach(async (timesheet) => {
if (frm.doc.currency != timesheet.currency) {
- frappe.call({
- method: "erpnext.setup.utils.get_exchange_rate",
- args: {
- from_currency: timesheet.currency,
- to_currency: frm.doc.currency
- },
- callback: function(r) {
- if (r.message) {
- exchange_rate = r.message;
- frm.events.append_time_log(frm, timesheet, exchange_rate);
- }
- }
- });
+ const exchange_rate = await frm.events.get_exchange_rate(
+ frm, timesheet.currency, frm.doc.currency
+ )
+ frm.events.append_time_log(frm, timesheet, exchange_rate)
} else {
frm.events.append_time_log(frm, timesheet, 1.0);
}
});
},
+ async get_exchange_rate(frm, from_currency, to_currency) {
+ if (
+ frm.exchange_rates
+ && frm.exchange_rates[from_currency]
+ && frm.exchange_rates[from_currency][to_currency]
+ ) {
+ return frm.exchange_rates[from_currency][to_currency];
+ }
+
+ return frappe.call({
+ method: "erpnext.setup.utils.get_exchange_rate",
+ args: {
+ from_currency,
+ to_currency
+ },
+ callback: function(r) {
+ if (r.message) {
+ // cache exchange rates
+ frm.exchange_rates = frm.exchange_rates || {};
+ frm.exchange_rates[from_currency] = frm.exchange_rates[from_currency] || {};
+ frm.exchange_rates[from_currency][to_currency] = r.message;
+ }
+ }
+ });
+ },
+
append_time_log: function(frm, time_log, exchange_rate) {
const row = frm.add_child("timesheets");
row.activity_type = time_log.activity_type;
@@ -892,7 +908,7 @@ frappe.ui.form.on('Sales Invoice', {
row.billing_hours = time_log.billing_hours;
row.billing_amount = flt(time_log.billing_amount) * flt(exchange_rate);
row.timesheet_detail = time_log.name;
- row.project_name = time_log.project_name;
+ row.project_name = time_log.project_name;
frm.refresh_field("timesheets");
frm.trigger("calculate_timesheet_totals");
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index 80b95db8868..327545aa54e 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -1790,6 +1790,8 @@
"width": "50%"
},
{
+ "fetch_from": "sales_partner.commission_rate",
+ "fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"hide_days": 1,
@@ -2038,7 +2040,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2022-03-08 16:08:53.517903",
+ "modified": "2022-06-10 03:52:51.409913",
"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 f0880c19e3c..a580d45accf 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -396,7 +396,12 @@ class SalesInvoice(SellingController):
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference)
self.unlink_sales_invoice_from_timesheets()
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ self.ignore_linked_doctypes = (
+ "GL Entry",
+ "Stock Ledger Entry",
+ "Repost Item Valuation",
+ "Payment Ledger Entry",
+ )
def update_status_updater_args(self):
if cint(self.update_stock):
diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.json b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.json
index 19781bdffaa..408ecbf36dc 100644
--- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.json
+++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2013-01-10 16:34:09",
@@ -77,7 +78,8 @@
],
"icon": "fa fa-money",
"idx": 1,
- "modified": "2019-11-25 13:06:03.279099",
+ "links": [],
+ "modified": "2022-05-16 16:14:52.061672",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges Template",
@@ -113,7 +115,10 @@
"write": 1
}
],
+ "show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
+ "title_field": "title",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 1598d914e2d..b0513f16a59 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -14,6 +14,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
+from erpnext.accounts.utils import create_payment_ledger_entry
class ClosedAccountingPeriod(frappe.ValidationError):
@@ -34,6 +35,7 @@ def make_gl_entries(
validate_disabled_accounts(gl_map)
gl_map = process_gl_map(gl_map, merge_entries)
if gl_map and len(gl_map) > 1:
+ create_payment_ledger_entry(gl_map)
save_entries(gl_map, adv_adj, update_outstanding, from_repost)
# Post GL Map proccess there may no be any GL Entries
elif gl_map:
@@ -479,6 +481,7 @@ def make_reverse_gl_entries(
).run(as_dict=1)
if gl_entries:
+ create_payment_ledger_entry(gl_entries, cancel=1)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
diff --git a/erpnext/accounts/module_onboarding/accounts/accounts.json b/erpnext/accounts/module_onboarding/accounts/accounts.json
index aa7cdf788b0..b9040e33097 100644
--- a/erpnext/accounts/module_onboarding/accounts/accounts.json
+++ b/erpnext/accounts/module_onboarding/accounts/accounts.json
@@ -13,7 +13,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts",
"idx": 0,
"is_complete": 0,
- "modified": "2022-01-18 18:35:52.326688",
+ "modified": "2022-06-07 14:29:21.352132",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts",
diff --git a/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json b/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json
index 67553baec74..0973ab39626 100644
--- a/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json
+++ b/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json
@@ -1,8 +1,8 @@
{
- "action": "Watch Video",
+ "action": "Go to Page",
"action_label": "Learn more about Chart of Accounts",
"callback_message": "You can continue with the onboarding after exploring this page",
- "callback_title": "Awesome Work",
+ "callback_title": "Explore Chart of Accounts",
"creation": "2020-05-13 19:58:20.928127",
"description": "# Chart Of Accounts\n\nERPNext sets up a simple chart of accounts for each Company you create, but you can modify it according to business and legal requirements.",
"docstatus": 0,
@@ -12,7 +12,7 @@
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
- "modified": "2021-08-13 11:46:25.878506",
+ "modified": "2022-06-07 14:21:26.264769",
"modified_by": "Administrator",
"name": "Chart of Accounts",
"owner": "Administrator",
diff --git a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json
index 9f4c873e349..b6e9f5cd878 100644
--- a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json
+++ b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json
@@ -2,14 +2,14 @@
"action": "Create Entry",
"action_label": "Manage Sales Tax Templates",
"creation": "2020-05-13 19:29:43.844463",
- "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n",
+ "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n\n[Checkout pre-configured taxes](/app/sales-taxes-and-charges-template)\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
- "modified": "2021-08-13 11:48:37.238610",
+ "modified": "2022-06-07 14:27:15.906286",
"modified_by": "Administrator",
"name": "Setup Taxes",
"owner": "Administrator",
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index db741d97e10..f4a44bd3629 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -897,3 +897,18 @@ def get_default_contact(doctype, name):
return None
else:
return None
+
+
+def add_party_account(party_type, party, company, account):
+ doc = frappe.get_doc(party_type, party)
+ account_exists = False
+ for d in doc.get("accounts"):
+ if d.account == account:
+ account_exists = True
+
+ if not account_exists:
+ accounts = {"company": company, "account": account}
+
+ doc.append("accounts", accounts)
+
+ doc.save()
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html
index f4fd06ba037..f2bf9424f72 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html
@@ -42,7 +42,7 @@
{% if(filters.show_future_payments) { %}
{% var balance_row = data.slice(-1).pop();
- var start = filters.based_on_payment_terms ? 13 : 11;
+ var start = report.columns.findIndex((elem) => (elem.fieldname == 'age'));
var range1 = report.columns[start].label;
var range2 = report.columns[start+1].label;
var range3 = report.columns[start+2].label;
diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
index f3ccc868c4c..c41d0d10ffe 100644
--- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
+++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
@@ -198,10 +198,12 @@ def get_loan_entries(filters):
amount_field = (loan_doc.disbursed_amount).as_("credit")
posting_date = (loan_doc.disbursement_date).as_("posting_date")
account = loan_doc.disbursement_account
+ salary_condition = loan_doc.docstatus == 1
else:
amount_field = (loan_doc.amount_paid).as_("debit")
posting_date = (loan_doc.posting_date).as_("posting_date")
account = loan_doc.payment_account
+ salary_condition = loan_doc.repay_from_salary == 0
query = (
frappe.qb.from_(loan_doc)
@@ -214,14 +216,12 @@ def get_loan_entries(filters):
posting_date,
)
.where(loan_doc.docstatus == 1)
+ .where(salary_condition)
.where(account == filters.get("account"))
.where(posting_date <= getdate(filters.get("report_date")))
.where(ifnull(loan_doc.clearance_date, "4000-01-01") > getdate(filters.get("report_date")))
)
- if doctype == "Loan Repayment":
- query.where(loan_doc.repay_from_salary == 0)
-
entries = query.run(as_dict=1)
loan_docs.extend(entries)
@@ -267,15 +267,17 @@ def get_loan_amount(filters):
amount_field = Sum(loan_doc.disbursed_amount)
posting_date = (loan_doc.disbursement_date).as_("posting_date")
account = loan_doc.disbursement_account
+ salary_condition = loan_doc.docstatus == 1
else:
amount_field = Sum(loan_doc.amount_paid)
posting_date = (loan_doc.posting_date).as_("posting_date")
account = loan_doc.payment_account
-
+ salary_condition = loan_doc.repay_from_salary == 0
amount = (
frappe.qb.from_(loan_doc)
.select(amount_field)
.where(loan_doc.docstatus == 1)
+ .where(salary_condition)
.where(account == filters.get("account"))
.where(posting_date > getdate(filters.get("report_date")))
.where(ifnull(loan_doc.clearance_date, "4000-01-01") <= getdate(filters.get("report_date")))
diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py
index 74926b90ffa..75e983afc0e 100644
--- a/erpnext/accounts/report/cash_flow/cash_flow.py
+++ b/erpnext/accounts/report/cash_flow/cash_flow.py
@@ -262,7 +262,10 @@ def get_report_summary(summary_data, currency):
def get_chart_data(columns, data):
labels = [d.get("label") for d in columns[2:]]
datasets = [
- {"name": account.get("account").replace("'", ""), "values": [account.get("total")]}
+ {
+ "name": account.get("account").replace("'", ""),
+ "values": [account.get(d.get("fieldname")) for d in columns[2:]],
+ }
for account in data
if account.get("parent_account") == None and account.get("currency")
]
diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js
index d3e836afd10..dd965a9813e 100644
--- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js
+++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js
@@ -50,7 +50,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
"fieldtype": "Link",
"options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"),
- "reqd": 1
+ "reqd": 1,
+ on_change: () => {
+ frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) {
+ let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date");
+ frappe.query_report.set_filter_value({
+ period_start_date: year_start_date
+ });
+ });
+ }
},
{
"fieldname":"to_fiscal_year",
@@ -58,7 +66,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
"fieldtype": "Link",
"options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"),
- "reqd": 1
+ "reqd": 1,
+ on_change: () => {
+ frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) {
+ let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date");
+ frappe.query_report.set_filter_value({
+ period_end_date: year_end_date
+ });
+ });
+ }
},
{
"fieldname":"finance_book",
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 158ff4d3437..3d37b5898c1 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -35,7 +35,7 @@ frappe.query_reports["Gross Profit"] = {
"fieldname":"group_by",
"label": __("Group By"),
"fieldtype": "Select",
- "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject",
+ "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject\nMonthly\nPayment Term",
"default": "Invoice"
},
],
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index 9668992e022..526ea9d6e2e 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -4,7 +4,7 @@
import frappe
from frappe import _, scrub
-from frappe.utils import cint, flt
+from frappe.utils import cint, flt, formatdate
from erpnext.controllers.queries import get_match_cond
from erpnext.stock.utils import get_incoming_rate
@@ -124,6 +124,23 @@ def execute(filters=None):
"gross_profit",
"gross_profit_percent",
],
+ "monthly": [
+ "monthly",
+ "qty",
+ "base_rate",
+ "buying_rate",
+ "base_amount",
+ "buying_amount",
+ "gross_profit",
+ "gross_profit_percent",
+ ],
+ "payment_term": [
+ "payment_term",
+ "base_amount",
+ "buying_amount",
+ "gross_profit",
+ "gross_profit_percent",
+ ],
}
)
@@ -317,6 +334,19 @@ def get_columns(group_wise_columns, filters):
"options": "territory",
"width": 100,
},
+ "monthly": {
+ "label": _("Monthly"),
+ "fieldname": "monthly",
+ "fieldtype": "Data",
+ "width": 100,
+ },
+ "payment_term": {
+ "label": _("Payment Term"),
+ "fieldname": "payment_term",
+ "fieldtype": "Link",
+ "options": "Payment Term",
+ "width": 170,
+ },
}
)
@@ -390,6 +420,9 @@ class GrossProfitGenerator(object):
buying_amount = 0
for row in reversed(self.si_list):
+ if self.filters.get("group_by") == "Monthly":
+ row.monthly = formatdate(row.posting_date, "MMM YYYY")
+
if self.skip_row(row):
continue
@@ -445,17 +478,7 @@ class GrossProfitGenerator(object):
def get_average_rate_based_on_group_by(self):
for key in list(self.grouped):
- if self.filters.get("group_by") != "Invoice":
- for i, row in enumerate(self.grouped[key]):
- if i == 0:
- new_row = row
- else:
- new_row.qty += flt(row.qty)
- new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
- new_row.base_amount += flt(row.base_amount, self.currency_precision)
- new_row = self.set_average_rate(new_row)
- self.grouped_data.append(new_row)
- else:
+ if self.filters.get("group_by") == "Invoice":
for i, row in enumerate(self.grouped[key]):
if row.indent == 1.0:
if (
@@ -469,6 +492,44 @@ class GrossProfitGenerator(object):
if flt(row.qty) or row.base_amount:
row = self.set_average_rate(row)
self.grouped_data.append(row)
+ elif self.filters.get("group_by") == "Payment Term":
+ for i, row in enumerate(self.grouped[key]):
+ invoice_portion = 0
+
+ if row.is_return:
+ invoice_portion = 100
+ elif row.invoice_portion:
+ invoice_portion = row.invoice_portion
+ else:
+ invoice_portion = row.payment_amount * 100 / row.base_net_amount
+
+ if i == 0:
+ new_row = row
+ self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion)
+ else:
+ new_row.qty += flt(row.qty)
+ self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True)
+
+ new_row = self.set_average_rate(new_row)
+ self.grouped_data.append(new_row)
+ else:
+ for i, row in enumerate(self.grouped[key]):
+ if i == 0:
+ new_row = row
+ else:
+ new_row.qty += flt(row.qty)
+ new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
+ new_row.base_amount += flt(row.base_amount, self.currency_precision)
+ new_row = self.set_average_rate(new_row)
+ self.grouped_data.append(new_row)
+
+ def set_average_based_on_payment_term_portion(self, new_row, row, invoice_portion, aggr=False):
+ cols = ["base_amount", "buying_amount", "gross_profit"]
+ for col in cols:
+ if aggr:
+ new_row[col] += row[col] * invoice_portion / 100
+ else:
+ new_row[col] = row[col] * invoice_portion / 100
def is_not_invoice_row(self, row):
return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get(
@@ -622,6 +683,20 @@ class GrossProfitGenerator(object):
sales_person_cols = ""
sales_team_table = ""
+ if self.filters.group_by == "Payment Term":
+ payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
+ '{0}',
+ coalesce(schedule.payment_term, '{1}')) as payment_term,
+ schedule.invoice_portion,
+ schedule.payment_amount """.format(
+ _("Sales Return"), _("No Terms")
+ )
+ payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
+ `tabSales Invoice`.is_return = 0 """
+ else:
+ payment_term_cols = ""
+ payment_term_table = ""
+
if self.filters.get("sales_invoice"):
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
@@ -644,10 +719,12 @@ class GrossProfitGenerator(object):
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
`tabSales Invoice Item`.cost_center
{sales_person_cols}
+ {payment_term_cols}
from
`tabSales Invoice` inner join `tabSales Invoice Item`
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
{sales_team_table}
+ {payment_term_table}
where
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
order by
@@ -655,6 +732,8 @@ class GrossProfitGenerator(object):
conditions=conditions,
sales_person_cols=sales_person_cols,
sales_team_table=sales_team_table,
+ payment_term_cols=payment_term_cols,
+ payment_term_table=payment_term_table,
match_cond=get_match_cond("Sales Invoice"),
),
self.filters,
diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
index 2e7213f42b1..ac706666547 100644
--- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
+++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
@@ -443,12 +443,6 @@ def get_grand_total(filters, doctype):
] # nosec
-def get_deducted_taxes():
- return frappe.db.sql_list(
- "select name from `tabPurchase Taxes and Charges` where add_deduct_tax = 'Deduct'"
- )
-
-
def get_tax_accounts(
item_list,
columns,
@@ -462,6 +456,7 @@ def get_tax_accounts(
tax_columns = []
invoice_item_row = {}
itemised_tax = {}
+ add_deduct_tax = "charge_type"
tax_amount_precision = (
get_field_precision(
@@ -477,13 +472,13 @@ def get_tax_accounts(
conditions = ""
if doctype == "Purchase Invoice":
conditions = " and category in ('Total', 'Valuation and Total') and base_tax_amount_after_discount_amount != 0"
+ add_deduct_tax = "add_deduct_tax"
- deducted_tax = get_deducted_taxes()
tax_details = frappe.db.sql(
"""
select
name, parent, description, item_wise_tax_detail,
- charge_type, base_tax_amount_after_discount_amount
+ charge_type, {add_deduct_tax}, base_tax_amount_after_discount_amount
from `tab%s`
where
parenttype = %s and docstatus = 1
@@ -491,12 +486,22 @@ def get_tax_accounts(
and parent in (%s)
%s
order by description
- """
+ """.format(
+ add_deduct_tax=add_deduct_tax
+ )
% (tax_doctype, "%s", ", ".join(["%s"] * len(invoice_item_row)), conditions),
tuple([doctype] + list(invoice_item_row)),
)
- for name, parent, description, item_wise_tax_detail, charge_type, tax_amount in tax_details:
+ for (
+ name,
+ parent,
+ description,
+ item_wise_tax_detail,
+ charge_type,
+ add_deduct_tax,
+ tax_amount,
+ ) in tax_details:
description = handle_html(description)
if description not in tax_columns and tax_amount:
# as description is text editor earlier and markup can break the column convention in reports
@@ -529,7 +534,9 @@ def get_tax_accounts(
if item_tax_amount:
tax_value = flt(item_tax_amount, tax_amount_precision)
tax_value = (
- tax_value * -1 if (doctype == "Purchase Invoice" and name in deducted_tax) else tax_value
+ tax_value * -1
+ if (doctype == "Purchase Invoice" and add_deduct_tax == "Deduct")
+ else tax_value
)
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
index 3e7aa1e3680..183e279fe5d 100644
--- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
+++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
@@ -211,6 +211,7 @@ def set_gl_entries_by_account(
{additional_conditions}
and posting_date <= %(to_date)s
and {based_on} is not null
+ and is_cancelled = 0
order by {based_on}, posting_date""".format(
additional_conditions="\n".join(additional_conditions), based_on=based_on
),
diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py
index 34b3f032068..33bd3c74965 100644
--- a/erpnext/accounts/report/sales_register/sales_register.py
+++ b/erpnext/accounts/report/sales_register/sales_register.py
@@ -346,9 +346,13 @@ def get_columns(invoice_list, additional_table_columns):
def get_conditions(filters):
conditions = ""
+ accounting_dimensions = get_accounting_dimensions(as_list=False) or []
+ accounting_dimensions_list = [d.fieldname for d in accounting_dimensions]
+
if filters.get("company"):
conditions += " and company=%(company)s"
- if filters.get("customer"):
+
+ if filters.get("customer") and "customer" not in accounting_dimensions_list:
conditions += " and customer = %(customer)s"
if filters.get("from_date"):
@@ -359,32 +363,18 @@ def get_conditions(filters):
if filters.get("owner"):
conditions += " and owner = %(owner)s"
- if filters.get("mode_of_payment"):
- conditions += """ and exists(select name from `tabSales Invoice Payment`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Payment`.mode_of_payment, '') = %(mode_of_payment)s)"""
+ def get_sales_invoice_item_field_condition(field, table="Sales Invoice Item") -> str:
+ if not filters.get(field) or field in accounting_dimensions_list:
+ return ""
+ return f""" and exists(select name from `tab{table}`
+ where parent=`tabSales Invoice`.name
+ and ifnull(`tab{table}`.{field}, '') = %({field})s)"""
- if filters.get("cost_center"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.cost_center, '') = %(cost_center)s)"""
-
- if filters.get("warehouse"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.warehouse, '') = %(warehouse)s)"""
-
- if filters.get("brand"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.brand, '') = %(brand)s)"""
-
- if filters.get("item_group"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.item_group, '') = %(item_group)s)"""
-
- accounting_dimensions = get_accounting_dimensions(as_list=False)
+ conditions += get_sales_invoice_item_field_condition("mode_of_payments", "Sales Invoice Payment")
+ conditions += get_sales_invoice_item_field_condition("cost_center")
+ conditions += get_sales_invoice_item_field_condition("warehouse")
+ conditions += get_sales_invoice_item_field_condition("brand")
+ conditions += get_sales_invoice_item_field_condition("item_group")
if accounting_dimensions:
common_condition = """
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py
index e5a4ed2f347..6bd08ad837a 100644
--- a/erpnext/accounts/report/trial_balance/trial_balance.py
+++ b/erpnext/accounts/report/trial_balance/trial_balance.py
@@ -160,14 +160,12 @@ def get_rootwise_opening_balances(filters, report_type):
if filters.project:
additional_conditions += " and project = %(project)s"
- if filters.finance_book:
- fb_conditions = " AND finance_book = %(finance_book)s"
- if filters.include_default_book_entries:
- fb_conditions = (
- " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)"
- )
-
- additional_conditions += fb_conditions
+ if filters.get("include_default_book_entries"):
+ additional_conditions += (
+ " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)"
+ )
+ else:
+ additional_conditions += " AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)"
accounting_dimensions = get_accounting_dimensions(as_list=False)
diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py
index 19fe74fffc1..3f06c30adb6 100644
--- a/erpnext/accounts/test/test_reports.py
+++ b/erpnext/accounts/test/test_reports.py
@@ -28,6 +28,7 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [
("Item-wise Sales Register", {}),
("Item-wise Purchase Register", {}),
("Sales Register", {}),
+ ("Sales Register", {"item_group": "All Item Groups"}),
("Purchase Register", {}),
(
"Tax Detail",
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 405922e16ef..8711395d558 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -7,7 +7,7 @@ from typing import List, Tuple
import frappe
import frappe.defaults
-from frappe import _, throw
+from frappe import _, qb, throw
from frappe.model.meta import get_field_precision
from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate
@@ -15,6 +15,7 @@ import erpnext
# imported to enable erpnext.accounts.utils.get_account_currency
from erpnext.accounts.doctype.account.account import get_account_currency # noqa
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.utils import get_stock_value_on
@@ -1123,6 +1124,9 @@ def update_gl_entries_after(
def repost_gle_for_stock_vouchers(
stock_vouchers, posting_date, company=None, warehouse_account=None
):
+
+ from erpnext.accounts.general_ledger import toggle_debit_credit_if_negative
+
if not stock_vouchers:
return
@@ -1141,10 +1145,12 @@ def repost_gle_for_stock_vouchers(
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2
gle = get_voucherwise_gl_entries(stock_vouchers, posting_date)
- for voucher_type, voucher_no in stock_vouchers:
+ for idx, (voucher_type, voucher_no) in enumerate(stock_vouchers):
existing_gle = gle.get((voucher_type, voucher_no), [])
- voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no)
- expected_gle = voucher_obj.get_gl_entries(warehouse_account)
+ voucher_obj = frappe.get_doc(voucher_type, voucher_no)
+ # Some transactions post credit as negative debit, this is handled while posting GLE
+ # but while comparing we need to make sure it's flipped so comparisons are accurate
+ expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account))
if expected_gle:
if not existing_gle or not compare_existing_and_expected_gle(
existing_gle, expected_gle, precision
@@ -1154,6 +1160,11 @@ def repost_gle_for_stock_vouchers(
else:
_delete_gl_entries(voucher_type, voucher_no)
+ if idx % 20 == 0:
+ # Commit every 20 documents to avoid losing progress
+ # and reducing memory usage
+ frappe.db.commit()
+
def sort_stock_vouchers_by_posting_date(
stock_vouchers: List[Tuple[str, str]]
@@ -1345,3 +1356,102 @@ def check_and_delete_linked_reports(report):
if icons:
for icon in icons:
frappe.delete_doc("Desktop Icon", icon)
+
+
+def create_payment_ledger_entry(gl_entries, cancel=0):
+ if gl_entries:
+ ple = None
+
+ # companies
+ account = qb.DocType("Account")
+ companies = list(set([x.company for x in gl_entries]))
+
+ # receivable/payable account
+ accounts_with_types = (
+ qb.from_(account)
+ .select(account.name, account.account_type)
+ .where(
+ (account.account_type.isin(["Receivable", "Payable"]) & (account.company.isin(companies)))
+ )
+ .run(as_dict=True)
+ )
+ receivable_or_payable_accounts = [y.name for y in accounts_with_types]
+
+ def get_account_type(account):
+ for entry in accounts_with_types:
+ if entry.name == account:
+ return entry.account_type
+
+ dr_or_cr = 0
+ account_type = None
+ for gle in gl_entries:
+ if gle.account in receivable_or_payable_accounts:
+ account_type = get_account_type(gle.account)
+ if account_type == "Receivable":
+ dr_or_cr = gle.debit - gle.credit
+ dr_or_cr_account_currency = gle.debit_in_account_currency - gle.credit_in_account_currency
+ elif account_type == "Payable":
+ dr_or_cr = gle.credit - gle.debit
+ dr_or_cr_account_currency = gle.credit_in_account_currency - gle.debit_in_account_currency
+
+ if cancel:
+ dr_or_cr *= -1
+ dr_or_cr_account_currency *= -1
+
+ ple = frappe.get_doc(
+ {
+ "doctype": "Payment Ledger Entry",
+ "posting_date": gle.posting_date,
+ "company": gle.company,
+ "account_type": account_type,
+ "account": gle.account,
+ "party_type": gle.party_type,
+ "party": gle.party,
+ "cost_center": gle.cost_center,
+ "finance_book": gle.finance_book,
+ "due_date": gle.due_date,
+ "voucher_type": gle.voucher_type,
+ "voucher_no": gle.voucher_no,
+ "against_voucher_type": gle.against_voucher_type
+ if gle.against_voucher_type
+ else gle.voucher_type,
+ "against_voucher_no": gle.against_voucher if gle.against_voucher else gle.voucher_no,
+ "currency": gle.currency,
+ "amount": dr_or_cr,
+ "amount_in_account_currency": dr_or_cr_account_currency,
+ "delinked": True if cancel else False,
+ }
+ )
+
+ dimensions_and_defaults = get_dimensions()
+ if dimensions_and_defaults:
+ for dimension in dimensions_and_defaults[0]:
+ ple.set(dimension.fieldname, gle.get(dimension.fieldname))
+
+ if cancel:
+ delink_original_entry(ple)
+ ple.flags.ignore_permissions = 1
+ ple.submit()
+
+
+def delink_original_entry(pl_entry):
+ if pl_entry:
+ ple = qb.DocType("Payment Ledger Entry")
+ query = (
+ qb.update(ple)
+ .set(ple.delinked, True)
+ .set(ple.modified, now())
+ .set(ple.modified_by, frappe.session.user)
+ .where(
+ (ple.company == pl_entry.company)
+ & (ple.account_type == pl_entry.account_type)
+ & (ple.account == pl_entry.account)
+ & (ple.party_type == pl_entry.party_type)
+ & (ple.party == pl_entry.party)
+ & (ple.voucher_type == pl_entry.voucher_type)
+ & (ple.voucher_no == pl_entry.voucher_no)
+ & (ple.against_voucher_type == pl_entry.against_voucher_type)
+ & (ple.against_voucher_no == pl_entry.against_voucher_no)
+ )
+ )
+ query.run()
diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json
index a456c7fb57a..61f62254593 100644
--- a/erpnext/accounts/workspace/accounting/accounting.json
+++ b/erpnext/accounts/workspace/accounting/accounting.json
@@ -504,18 +504,6 @@
"onboard": 0,
"type": "Link"
},
- {
- "dependencies": "GL Entry",
- "hidden": 0,
- "is_query_report": 1,
- "label": "DATEV Export",
- "link_count": 0,
- "link_to": "DATEV",
- "link_type": "Report",
- "onboard": 0,
- "only_for": "Germany",
- "type": "Link"
- },
{
"dependencies": "GL Entry",
"hidden": 0,
@@ -1024,16 +1012,16 @@
"type": "Link"
},
{
- "dependencies": "Cost Center",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Cost Center Allocation",
- "link_count": 0,
- "link_to": "Cost Center Allocation",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
+ "dependencies": "Cost Center",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Cost Center Allocation",
+ "link_count": 0,
+ "link_to": "Cost Center Allocation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"dependencies": "Cost Center",
"hidden": 0,
@@ -1235,13 +1223,14 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:25:09.835345",
+ "modified": "2022-06-10 15:49:42.990860",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounting",
"owner": "Administrator",
"parent_page": "",
"public": 1,
+ "quick_lists": [],
"restrict_to_domain": "",
"roles": [],
"sequence_id": 2.0,
diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json
index 89a94487163..6c18a4650b7 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.json
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -148,7 +148,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-04-14 15:56:42.340223",
+ "modified": "2022-05-31 19:40:26.103909",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
@@ -162,6 +162,16 @@
"role": "System Manager",
"share": 1,
"write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "role": "Purchase Manager",
+ "share": 1,
+ "write": 1
}
],
"sort_field": "modified",
diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py
index c52b59e4c0c..7b18cdbedcd 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.py
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.py
@@ -18,7 +18,7 @@ class BuyingSettings(Document):
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
frappe.db.set_default(key, self.get(key, ""))
- from erpnext.setup.doctype.naming_series.naming_series import set_by_naming_series
+ from erpnext.utilities.naming import set_by_naming_series
set_by_naming_series(
"Supplier",
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index c9e67987c6b..da45610eaff 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -43,8 +43,6 @@ frappe.ui.form.on("Purchase Order", {
erpnext.queries.setup_queries(frm, "Warehouse", function() {
return erpnext.queries.warehouse(frm.doc);
});
-
- erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
},
apply_tds: function(frm) {
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 9189f183738..44426ba43d2 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -323,6 +323,7 @@ class PurchaseOrder(BuyingController):
update_linked_doc(self.doctype, self.name, self.inter_company_order_reference)
def on_cancel(self):
+ self.ignore_linked_doctypes = "Payment Ledger Entry"
super(PurchaseOrder, self).on_cancel()
if self.is_against_so():
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index 97d0ba0b9c1..43152e89a83 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -84,6 +84,9 @@ class Supplier(TransactionBase):
self.save()
def validate_internal_supplier(self):
+ if not self.is_internal_supplier:
+ self.represents_company = ""
+
internal_supplier = frappe.db.get_value(
"Supplier",
{
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 7761551227e..854c0d00f54 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -34,6 +34,7 @@ from erpnext.accounts.doctype.pricing_rule.utils import (
from erpnext.accounts.party import (
get_party_account,
get_party_account_currency,
+ get_party_gle_currency,
validate_party_frozen_disabled,
)
from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year
@@ -168,6 +169,7 @@ class AccountsController(TransactionBase):
self.validate_party()
self.validate_currency()
+ self.validate_party_account_currency()
if self.doctype in ["Purchase Invoice", "Sales Invoice"]:
pos_check_field = "is_pos" if self.doctype == "Sales Invoice" else "is_paid"
@@ -1130,11 +1132,10 @@ class AccountsController(TransactionBase):
{
"account": item.discount_account,
"against": supplier_or_customer,
- dr_or_cr: flt(discount_amount, item.precision("discount_amount")),
- dr_or_cr
- + "_in_account_currency": flt(
+ dr_or_cr: flt(
discount_amount * self.get("conversion_rate"), item.precision("discount_amount")
),
+ dr_or_cr + "_in_account_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project,
},
@@ -1149,11 +1150,11 @@ class AccountsController(TransactionBase):
{
"account": income_or_expense_account,
"against": supplier_or_customer,
- rev_dr_cr: flt(discount_amount, item.precision("discount_amount")),
- rev_dr_cr
- + "_in_account_currency": flt(
+ rev_dr_cr: flt(
discount_amount * self.get("conversion_rate"), item.precision("discount_amount")
),
+ rev_dr_cr
+ + "_in_account_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project or self.project,
},
@@ -1448,6 +1449,27 @@ class AccountsController(TransactionBase):
# at quotation / sales order level and we shouldn't stop someone
# from creating a sales invoice if sales order is already created
+ def validate_party_account_currency(self):
+ if self.doctype not in ("Sales Invoice", "Purchase Invoice"):
+ return
+
+ if self.is_opening == "Yes":
+ return
+
+ party_type, party = self.get_party()
+ party_gle_currency = get_party_gle_currency(party_type, party, self.company)
+ party_account = (
+ self.get("debit_to") if self.doctype == "Sales Invoice" else self.get("credit_to")
+ )
+ party_account_currency = get_account_currency(party_account)
+
+ if not party_gle_currency and (party_account_currency != self.currency):
+ frappe.throw(
+ _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format(
+ frappe.bold(party_account), party_account_currency, self.currency
+ )
+ )
+
def delink_advance_entries(self, linked_doc_name):
total_allocated_amount = 0
for adv in self.advances:
@@ -1844,7 +1866,7 @@ def get_default_taxes_and_charges(master_doctype, tax_template=None, company=Non
def get_taxes_and_charges(master_doctype, master_name):
if not master_name:
return
- from frappe.model import default_fields
+ from frappe.model import child_table_fields, default_fields
tax_master = frappe.get_doc(master_doctype, master_name)
@@ -1852,7 +1874,7 @@ def get_taxes_and_charges(master_doctype, master_name):
for i, tax in enumerate(tax_master.get("taxes")):
tax = tax.as_dict()
- for fieldname in default_fields:
+ for fieldname in default_fields + child_table_fields:
if fieldname in tax:
del tax[fieldname]
@@ -2639,7 +2661,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.update_reserved_qty_for_subcontract()
parent.create_raw_materials_supplied("supplied_items")
parent.save()
- else:
+ else: # Sales Order
+ parent.validate_warehouse()
parent.update_reserved_qty()
parent.update_project()
parent.update_prevdoc_status("submit")
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index bd4b59b3855..d24ac3f2cf3 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -316,7 +316,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype):
return data[0]
-def make_return_doc(doctype, source_name, target_doc=None):
+def make_return_doc(doctype: str, source_name: str, target_doc=None):
from frappe.model.mapper import get_mapped_doc
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py
index 02ec3bf1f3f..f6fea72f8a4 100644
--- a/erpnext/e_commerce/doctype/website_item/website_item.py
+++ b/erpnext/e_commerce/doctype/website_item/website_item.py
@@ -34,9 +34,7 @@ class WebsiteItem(WebsiteGenerator):
def autoname(self):
# use naming series to accomodate items with same name (different item code)
- from frappe.model.naming import make_autoname
-
- from erpnext.setup.doctype.naming_series.naming_series import get_default_naming_series
+ from frappe.model.naming import get_default_naming_series, make_autoname
naming_series = get_default_naming_series("Website Item")
if not self.name and naming_series:
diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py
index 61b4b9ee1f4..1f649c7b486 100644
--- a/erpnext/e_commerce/redisearch_utils.py
+++ b/erpnext/e_commerce/redisearch_utils.py
@@ -38,7 +38,7 @@ def is_search_module_loaded():
out = cache.execute_command("MODULE LIST")
parsed_output = " ".join(
- (" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out)
+ (" ".join([frappe.as_unicode(s) for s in o if not isinstance(s, int)]) for o in out)
)
return "search" in parsed_output
except Exception:
diff --git a/erpnext/erpnext_integrations/connectors/github_connection.py b/erpnext/erpnext_integrations/connectors/github_connection.py
deleted file mode 100644
index f28065e7248..00000000000
--- a/erpnext/erpnext_integrations/connectors/github_connection.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import frappe
-from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection
-from github import Github
-
-class GithubConnection(BaseConnection):
- def __init__(self, connector):
- self.connector = connector
-
- try:
- password = self.get_password()
- except frappe.AuthenticationError:
- password = None
-
- if self.connector.username and password:
- self.connection = Github(self.connector.username, self.get_password())
- else:
- self.connection = Github()
-
- self.name_field = 'id'
-
- def insert(self, doctype, doc):
- pass
-
- def update(self, doctype, doc, migration_id):
- pass
-
- def delete(self, doctype, migration_id):
- pass
-
- def get(self, remote_objectname, fields=None, filters=None, start=0, page_length=10):
- repo = filters.get('repo')
-
- if remote_objectname == 'Milestone':
- return self.get_milestones(repo, start, page_length)
- if remote_objectname == 'Issue':
- return self.get_issues(repo, start, page_length)
-
- def get_milestones(self, repo, start=0, page_length=10):
- _repo = self.connection.get_repo(repo)
- return list(_repo.get_milestones()[start:start+page_length])
-
- def get_issues(self, repo, start=0, page_length=10):
- _repo = self.connection.get_repo(repo)
- return list(_repo.get_issues()[start:start+page_length])
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py
deleted file mode 100644
index 616ecfbac68..00000000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import frappe
-
-
-def pre_process(issue):
-
- project = frappe.db.get_value("Project", filters={"project_name": issue.milestone})
- return {
- "title": issue.title,
- "body": frappe.utils.md_to_html(issue.body or ""),
- "state": issue.state.title(),
- "project": project or "",
- }
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json
deleted file mode 100644
index e945ba22617..00000000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "condition": "{\"repo\":\"frappe/erpnext\"}",
- "creation": "2017-10-16 16:03:32.772191",
- "docstatus": 0,
- "doctype": "Data Migration Mapping",
- "fields": [
- {
- "is_child_table": 0,
- "local_fieldname": "subject",
- "remote_fieldname": "title"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "description",
- "remote_fieldname": "body"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "status",
- "remote_fieldname": "state"
- }
- ],
- "idx": 0,
- "local_doctype": "Task",
- "local_primary_key": "name",
- "mapping_name": "Issue to Task",
- "mapping_type": "Pull",
- "migration_id_field": "github_sync_id",
- "modified": "2017-10-20 11:48:54.575993",
- "modified_by": "Administrator",
- "name": "Issue to Task",
- "owner": "Administrator",
- "page_length": 10,
- "remote_objectname": "Issue",
- "remote_primary_key": "id"
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py
deleted file mode 100644
index d44fc0454ca..00000000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-def pre_process(milestone):
- return {
- "title": milestone.title,
- "description": milestone.description,
- "state": milestone.state.title(),
- }
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json
deleted file mode 100644
index 5a3e07e37ea..00000000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "condition": "{\"repo\": \"frappe/erpnext\"}",
- "creation": "2017-10-13 11:16:49.664925",
- "docstatus": 0,
- "doctype": "Data Migration Mapping",
- "fields": [
- {
- "is_child_table": 0,
- "local_fieldname": "project_name",
- "remote_fieldname": "title"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "notes",
- "remote_fieldname": "description"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "status",
- "remote_fieldname": "state"
- }
- ],
- "idx": 0,
- "local_doctype": "Project",
- "local_primary_key": "project_name",
- "mapping_name": "Milestone to Project",
- "mapping_type": "Pull",
- "migration_id_field": "github_sync_id",
- "modified": "2017-10-20 11:48:54.552305",
- "modified_by": "Administrator",
- "name": "Milestone to Project",
- "owner": "Administrator",
- "page_length": 10,
- "remote_objectname": "Milestone",
- "remote_primary_key": "id"
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json b/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json
deleted file mode 100644
index 20eb387cd85..00000000000
--- a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "creation": "2017-10-13 11:16:53.600026",
- "docstatus": 0,
- "doctype": "Data Migration Plan",
- "idx": 0,
- "mappings": [
- {
- "enabled": 1,
- "mapping": "Milestone to Project"
- },
- {
- "enabled": 1,
- "mapping": "Issue to Task"
- }
- ],
- "modified": "2017-10-20 11:48:54.496123",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "GitHub Sync",
- "owner": "Administrator",
- "plan_name": "GitHub Sync"
-}
\ No newline at end of file
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 813ac17ca01..7d7f65dfd7a 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -392,9 +392,12 @@ after_migrate = ["erpnext.setup.install.update_select_perm_after_install"]
scheduler_events = {
"cron": {
+ "0/5 * * * *": [
+ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
+ ],
"0/30 * * * *": [
"erpnext.utilities.doctype.video.video.update_youtube_data",
- ]
+ ],
},
"all": [
"erpnext.projects.doctype.project.project.project_status_update_reminder",
@@ -487,6 +490,7 @@ communication_doctypes = ["Customer", "Supplier"]
accounting_dimension_doctypes = [
"GL Entry",
+ "Payment Ledger Entry",
"Sales Invoice",
"Purchase Invoice",
"Payment Entry",
diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py
index e43d40ef567..f3cae8089c5 100644
--- a/erpnext/hr/doctype/attendance/attendance.py
+++ b/erpnext/hr/doctype/attendance/attendance.py
@@ -32,6 +32,9 @@ class Attendance(Document):
self.validate_employee_status()
self.check_leave_record()
+ def on_cancel(self):
+ self.unlink_attendance_from_checkins()
+
def validate_attendance_date(self):
date_of_joining = frappe.db.get_value("Employee", self.employee, "date_of_joining")
@@ -127,6 +130,33 @@ class Attendance(Document):
if not emp:
frappe.throw(_("Employee {0} is not active or does not exist").format(self.employee))
+ def unlink_attendance_from_checkins(self):
+ EmployeeCheckin = frappe.qb.DocType("Employee Checkin")
+ linked_logs = (
+ frappe.qb.from_(EmployeeCheckin)
+ .select(EmployeeCheckin.name)
+ .where(EmployeeCheckin.attendance == self.name)
+ .for_update()
+ .run(as_dict=True)
+ )
+
+ if linked_logs:
+ (
+ frappe.qb.update(EmployeeCheckin)
+ .set("attendance", "")
+ .where(EmployeeCheckin.attendance == self.name)
+ ).run()
+
+ frappe.msgprint(
+ msg=_("Unlinked Attendance record from Employee Checkins: {}").format(
+ ", ".join(get_link_to_form("Employee Checkin", log.name) for log in linked_logs)
+ ),
+ title=_("Unlinked logs"),
+ indicator="blue",
+ is_minimizable=True,
+ wide=True,
+ )
+
def get_duplicate_attendance_record(employee, attendance_date, shift, name=None):
attendance = frappe.qb.DocType("Attendance")
diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py
index 762d0f7567e..c85ec6551a4 100644
--- a/erpnext/hr/doctype/attendance/test_attendance.py
+++ b/erpnext/hr/doctype/attendance/test_attendance.py
@@ -3,7 +3,15 @@
import frappe
from frappe.tests.utils import FrappeTestCase
-from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ get_last_day,
+ get_year_ending,
+ get_year_start,
+ getdate,
+ nowdate,
+)
from erpnext.hr.doctype.attendance.attendance import (
DuplicateAttendanceError,
@@ -138,69 +146,70 @@ class TestAttendance(FrappeTestCase):
self.assertEqual(attendance, fetch_attendance)
def test_unmarked_days(self):
- now = now_datetime()
- previous_month = now.month - 1
- first_day = now.replace(day=1).replace(month=previous_month).date()
+ first_sunday = get_first_sunday(
+ self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
+ )
+ attendance_date = add_days(first_sunday, 1)
employee = make_employee(
- "test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
+ "test_unmarked_days@example.com", date_of_joining=add_days(attendance_date, -1)
)
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
- first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
- mark_attendance(employee, first_day, "Present")
- month_name = get_month_name(first_day)
+ mark_attendance(employee, attendance_date, "Present")
+ month_name = get_month_name(attendance_date)
unmarked_days = get_unmarked_days(employee, month_name)
unmarked_days = [getdate(date) for date in unmarked_days]
# attendance already marked for the day
- self.assertNotIn(first_day, unmarked_days)
+ self.assertNotIn(attendance_date, unmarked_days)
# attendance unmarked
- self.assertIn(getdate(add_days(first_day, 1)), unmarked_days)
+ self.assertIn(getdate(add_days(attendance_date, 1)), unmarked_days)
# holiday considered in unmarked days
self.assertIn(first_sunday, unmarked_days)
def test_unmarked_days_excluding_holidays(self):
- now = now_datetime()
- previous_month = now.month - 1
- first_day = now.replace(day=1).replace(month=previous_month).date()
+ first_sunday = get_first_sunday(
+ self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
+ )
+ attendance_date = add_days(first_sunday, 1)
employee = make_employee(
- "test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
+ "test_unmarked_days@example.com", date_of_joining=add_days(attendance_date, -1)
)
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
- first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
- mark_attendance(employee, first_day, "Present")
- month_name = get_month_name(first_day)
+ mark_attendance(employee, attendance_date, "Present")
+ month_name = get_month_name(attendance_date)
unmarked_days = get_unmarked_days(employee, month_name, exclude_holidays=True)
unmarked_days = [getdate(date) for date in unmarked_days]
# attendance already marked for the day
- self.assertNotIn(first_day, unmarked_days)
+ self.assertNotIn(attendance_date, unmarked_days)
# attendance unmarked
- self.assertIn(getdate(add_days(first_day, 1)), unmarked_days)
+ self.assertIn(getdate(add_days(attendance_date, 1)), unmarked_days)
# holidays not considered in unmarked days
self.assertNotIn(first_sunday, unmarked_days)
def test_unmarked_days_as_per_joining_and_relieving_dates(self):
- now = now_datetime()
- previous_month = now.month - 1
- first_day = now.replace(day=1).replace(month=previous_month).date()
+ first_sunday = get_first_sunday(
+ self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
+ )
+ date = add_days(first_sunday, 1)
- doj = add_days(first_day, 1)
- relieving_date = add_days(first_day, 5)
+ doj = add_days(date, 1)
+ relieving_date = add_days(date, 5)
employee = make_employee(
"test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date
)
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
- attendance_date = add_days(first_day, 2)
+ attendance_date = add_days(date, 2)
mark_attendance(employee, attendance_date, "Present")
- month_name = get_month_name(first_day)
+ month_name = get_month_name(attendance_date)
unmarked_days = get_unmarked_days(employee, month_name)
unmarked_days = [getdate(date) for date in unmarked_days]
diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json
index a3638e1a650..42479143e74 100644
--- a/erpnext/hr/doctype/employee/employee.json
+++ b/erpnext/hr/doctype/employee/employee.json
@@ -827,7 +827,7 @@
"idx": 24,
"image_field": "image",
"links": [],
- "modified": "2022-04-22 16:21:55.811983",
+ "modified": "2022-06-10 01:29:32.952091",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee",
@@ -872,7 +872,6 @@
],
"search_fields": "employee_name",
"show_name_in_global_search": 1,
- "show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py
index 1829bc4f2fc..f09d7ff75a7 100644
--- a/erpnext/hr/doctype/employee/employee_reminders.py
+++ b/erpnext/hr/doctype/employee/employee_reminders.py
@@ -230,7 +230,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
persons_name = anniversary_person
# Number of years completed at the company
completed_years = getdate().year - anniversary_persons[0]["date_of_joining"].year
- anniversary_person += f" completed {completed_years} year(s)"
+ anniversary_person += f" completed {get_pluralized_years(completed_years)}"
else:
person_names_with_years = []
names = []
@@ -239,7 +239,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
names.append(person_text)
# Number of years completed at the company
completed_years = getdate().year - person["date_of_joining"].year
- person_text += f" completed {completed_years} year(s)"
+ person_text += f" completed {get_pluralized_years(completed_years)}"
person_names_with_years.append(person_text)
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
@@ -254,6 +254,12 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
return reminder_text, message
+def get_pluralized_years(years):
+ if years == 1:
+ return "1 year"
+ return f"{years} years"
+
+
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail(
recipients=recipients,
diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py
index b603b3a6228..eb81f7d67c3 100644
--- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py
+++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py
@@ -76,6 +76,17 @@ class TestEmployeeCheckin(FrappeTestCase):
)
self.assertEqual(attendance_count, 1)
+ def test_unlink_attendance_on_cancellation(self):
+ employee = make_employee("test_mark_attendance_and_link_log@example.com")
+ logs = make_n_checkins(employee, 3)
+
+ frappe.db.delete("Attendance", {"employee": employee})
+ attendance = mark_attendance_and_link_log(logs, "Present", nowdate(), 8.2)
+ attendance.cancel()
+
+ linked_logs = frappe.db.get_all("Employee Checkin", {"attendance": attendance.name})
+ self.assertEquals(len(linked_logs), 0)
+
def test_calculate_working_hours(self):
check_in_out_type = [
"Alternating entries as IN and OUT during the same shift",
diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py
index 89d86c1bc7c..589763c0a91 100644
--- a/erpnext/hr/doctype/expense_claim/expense_claim.py
+++ b/erpnext/hr/doctype/expense_claim/expense_claim.py
@@ -105,7 +105,7 @@ class ExpenseClaim(AccountsController):
def on_cancel(self):
self.update_task_and_project()
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
+ self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
if self.payable_account:
self.make_gl_entries(cancel=True)
diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.py b/erpnext/hr/doctype/hr_settings/hr_settings.py
index 72a49e285a0..b56f3dbe0d7 100644
--- a/erpnext/hr/doctype/hr_settings/hr_settings.py
+++ b/erpnext/hr/doctype/hr_settings/hr_settings.py
@@ -22,7 +22,7 @@ class HRSettings(Document):
PROCEED_WITH_FREQUENCY_CHANGE = False
def set_naming_series(self):
- from erpnext.setup.doctype.naming_series.naming_series import set_by_naming_series
+ from erpnext.utilities.naming import set_by_naming_series
set_by_naming_series(
"Employee",
diff --git a/erpnext/hr/doctype/job_opening/job_opening.py b/erpnext/hr/doctype/job_opening/job_opening.py
index c71407d71d4..ce7caa33c68 100644
--- a/erpnext/hr/doctype/job_opening/job_opening.py
+++ b/erpnext/hr/doctype/job_opening/job_opening.py
@@ -6,6 +6,7 @@
import frappe
from frappe import _
+from frappe.utils import get_link_to_form
from frappe.website.website_generator import WebsiteGenerator
from erpnext.hr.doctype.staffing_plan.staffing_plan import (
@@ -33,26 +34,32 @@ class JobOpening(WebsiteGenerator):
self.staffing_plan = staffing_plan[0].name
self.planned_vacancies = staffing_plan[0].vacancies
elif not self.planned_vacancies:
- planned_vacancies = frappe.db.sql(
- """
- select vacancies from `tabStaffing Plan Detail`
- where parent=%s and designation=%s""",
- (self.staffing_plan, self.designation),
+ self.planned_vacancies = frappe.db.get_value(
+ "Staffing Plan Detail",
+ {"parent": self.staffing_plan, "designation": self.designation},
+ "vacancies",
)
- self.planned_vacancies = planned_vacancies[0][0] if planned_vacancies else None
if self.staffing_plan and self.planned_vacancies:
staffing_plan_company = frappe.db.get_value("Staffing Plan", self.staffing_plan, "company")
- lft, rgt = frappe.get_cached_value("Company", staffing_plan_company, ["lft", "rgt"])
- designation_counts = get_designation_counts(self.designation, self.company)
+ designation_counts = get_designation_counts(self.designation, self.company, self.name)
current_count = designation_counts["employee_count"] + designation_counts["job_openings"]
- if self.planned_vacancies <= current_count:
+ number_of_positions = frappe.db.get_value(
+ "Staffing Plan Detail",
+ {"parent": self.staffing_plan, "designation": self.designation},
+ "number_of_positions",
+ )
+
+ if number_of_positions <= current_count:
frappe.throw(
_(
- "Job Openings for designation {0} already open or hiring completed as per Staffing Plan {1}"
- ).format(self.designation, self.staffing_plan)
+ "Job Openings for the designation {0} are already open or the hiring is complete as per the Staffing Plan {1}"
+ ).format(
+ frappe.bold(self.designation), get_link_to_form("Staffing Plan", self.staffing_plan)
+ ),
+ title=_("Vacancies fulfilled"),
)
def get_context(self, context):
diff --git a/erpnext/hr/doctype/job_opening/test_job_opening.py b/erpnext/hr/doctype/job_opening/test_job_opening.py
index a72a6eb3384..e991054f62d 100644
--- a/erpnext/hr/doctype/job_opening/test_job_opening.py
+++ b/erpnext/hr/doctype/job_opening/test_job_opening.py
@@ -3,8 +3,77 @@
import unittest
-# test_records = frappe.get_test_records('Job Opening')
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, getdate
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.staffing_plan.test_staffing_plan import make_company
-class TestJobOpening(unittest.TestCase):
- pass
+class TestJobOpening(FrappeTestCase):
+ def setUp(self):
+ frappe.db.delete("Staffing Plan")
+ frappe.db.delete("Staffing Plan Detail")
+ frappe.db.delete("Job Opening")
+
+ make_company("_Test Opening Company", "_TOC")
+ frappe.db.delete("Employee", {"company": "_Test Opening Company"})
+
+ def test_vacancies_fulfilled(self):
+ make_employee(
+ "test_job_opening@example.com", company="_Test Opening Company", designation="Designer"
+ )
+
+ staffing_plan = frappe.get_doc(
+ {
+ "doctype": "Staffing Plan",
+ "company": "_Test Opening Company",
+ "name": "Test",
+ "from_date": getdate(),
+ "to_date": add_days(getdate(), 10),
+ }
+ )
+
+ staffing_plan.append(
+ "staffing_details",
+ {"designation": "Designer", "vacancies": 1, "estimated_cost_per_position": 50000},
+ )
+ staffing_plan.insert()
+ staffing_plan.submit()
+
+ self.assertEqual(staffing_plan.staffing_details[0].number_of_positions, 2)
+
+ # allows creating 1 job opening as per vacancy
+ opening_1 = get_job_opening()
+ opening_1.insert()
+
+ # vacancies as per staffing plan already fulfilled via job opening and existing employee count
+ opening_2 = get_job_opening(job_title="Designer New")
+ self.assertRaises(frappe.ValidationError, opening_2.insert)
+
+ # allows updating existing job opening
+ opening_1.status = "Closed"
+ opening_1.save()
+
+
+def get_job_opening(**args):
+ args = frappe._dict(args)
+
+ opening = frappe.db.exists("Job Opening", {"job_title": args.job_title or "Designer"})
+ if opening:
+ return frappe.get_doc("Job Opening", opening)
+
+ opening = frappe.get_doc(
+ {
+ "doctype": "Job Opening",
+ "job_title": "Designer",
+ "designation": "Designer",
+ "company": "_Test Opening Company",
+ "status": "Open",
+ }
+ )
+
+ opening.update(args)
+
+ return opening
diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js
index 85997a4087f..ee00e6719c0 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.js
+++ b/erpnext/hr/doctype/leave_application/leave_application.js
@@ -173,7 +173,7 @@ frappe.ui.form.on("Leave Application", {
date: frm.doc.from_date,
to_date: frm.doc.to_date,
leave_type: frm.doc.leave_type,
- consider_all_leaves_in_the_allocation_period: true
+ consider_all_leaves_in_the_allocation_period: 1
},
callback: function (r) {
if (!r.exc && r.message) {
diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py
index cd6b1686675..43c2bb37b21 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.py
+++ b/erpnext/hr/doctype/leave_application/leave_application.py
@@ -88,7 +88,7 @@ class LeaveApplication(Document):
share_doc_with_approver(self, self.leave_approver)
def on_submit(self):
- if self.status == "Open":
+ if self.status in ["Open", "Cancelled"]:
frappe.throw(
_("Only Leave Applications with status 'Approved' and 'Rejected' can be submitted")
)
@@ -757,22 +757,6 @@ def get_leave_details(employee, date):
leave_allocation = {}
for d in allocation_records:
allocation = allocation_records.get(d, frappe._dict())
-
- total_allocated_leaves = (
- frappe.db.get_value(
- "Leave Allocation",
- {
- "from_date": ("<=", date),
- "to_date": (">=", date),
- "employee": employee,
- "leave_type": allocation.leave_type,
- "docstatus": 1,
- },
- "SUM(total_leaves_allocated)",
- )
- or 0
- )
-
remaining_leaves = get_leave_balance_on(
employee, d, date, to_date=allocation.to_date, consider_all_leaves_in_the_allocation_period=True
)
@@ -782,10 +766,11 @@ def get_leave_details(employee, date):
leaves_pending = get_leaves_pending_approval_for_period(
employee, d, allocation.from_date, end_date
)
+ expired_leaves = allocation.total_leaves_allocated - (remaining_leaves + leaves_taken)
leave_allocation[d] = {
- "total_leaves": total_allocated_leaves,
- "expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken),
+ "total_leaves": allocation.total_leaves_allocated,
+ "expired_leaves": expired_leaves if expired_leaves > 0 else 0,
"leaves_taken": leaves_taken,
"leaves_pending_approval": leaves_pending,
"remaining_leaves": remaining_leaves,
@@ -830,7 +815,7 @@ def get_leave_balance_on(
allocation_records = get_leave_allocation_records(employee, date, leave_type)
allocation = allocation_records.get(leave_type, frappe._dict())
- end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date
+ end_date = allocation.to_date if cint(consider_all_leaves_in_the_allocation_period) else date
cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date)
leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date)
@@ -1117,7 +1102,7 @@ def add_leaves(events, start, end, filter_conditions=None):
WHERE
from_date <= %(end)s AND to_date >= %(start)s <= to_date
AND docstatus < 2
- AND status != 'Rejected'
+ AND status in ('Approved', 'Open')
"""
if conditions:
@@ -1201,24 +1186,33 @@ def get_mandatory_approval(doctype):
def get_approved_leaves_for_period(employee, leave_type, from_date, to_date):
- query = """
- select employee, leave_type, from_date, to_date, total_leave_days
- from `tabLeave Application`
- where employee=%(employee)s
- and docstatus=1
- and (from_date between %(from_date)s and %(to_date)s
- or to_date between %(from_date)s and %(to_date)s
- or (from_date < %(from_date)s and to_date > %(to_date)s))
- """
- if leave_type:
- query += "and leave_type=%(leave_type)s"
-
- leave_applications = frappe.db.sql(
- query,
- {"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type},
- as_dict=1,
+ LeaveApplication = frappe.qb.DocType("Leave Application")
+ query = (
+ frappe.qb.from_(LeaveApplication)
+ .select(
+ LeaveApplication.employee,
+ LeaveApplication.leave_type,
+ LeaveApplication.from_date,
+ LeaveApplication.to_date,
+ LeaveApplication.total_leave_days,
+ )
+ .where(
+ (LeaveApplication.employee == employee)
+ & (LeaveApplication.docstatus == 1)
+ & (LeaveApplication.status == "Approved")
+ & (
+ (LeaveApplication.from_date.between(from_date, to_date))
+ | (LeaveApplication.to_date.between(from_date, to_date))
+ | ((LeaveApplication.from_date < from_date) & (LeaveApplication.to_date > to_date))
+ )
+ )
)
+ if leave_type:
+ query = query.where(LeaveApplication.leave_type == leave_type)
+
+ leave_applications = query.run(as_dict=True)
+
leave_days = 0
for leave_app in leave_applications:
if leave_app.from_date >= getdate(from_date) and leave_app.to_date <= getdate(to_date):
diff --git a/erpnext/hr/doctype/leave_application/leave_application_list.js b/erpnext/hr/doctype/leave_application/leave_application_list.js
index a3c03b1bec7..157271a5a0e 100644
--- a/erpnext/hr/doctype/leave_application/leave_application_list.js
+++ b/erpnext/hr/doctype/leave_application/leave_application_list.js
@@ -1,13 +1,14 @@
-frappe.listview_settings['Leave Application'] = {
+frappe.listview_settings["Leave Application"] = {
add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"],
has_indicator_for_draft: 1,
get_indicator: function (doc) {
- if (doc.status === "Approved") {
- return [__("Approved"), "green", "status,=,Approved"];
- } else if (doc.status === "Rejected") {
- return [__("Rejected"), "red", "status,=,Rejected"];
- } else {
- return [__("Open"), "red", "status,=,Open"];
- }
+ let status_color = {
+ "Approved": "green",
+ "Rejected": "red",
+ "Open": "orange",
+ "Cancelled": "red",
+ "Submitted": "blue"
+ };
+ return [__(doc.status), status_color[doc.status], "status,=," + doc.status];
}
};
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 7506c611083..27c54109dea 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -76,7 +76,14 @@ _test_records = [
class TestLeaveApplication(unittest.TestCase):
def setUp(self):
- for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]:
+ for dt in [
+ "Leave Application",
+ "Leave Allocation",
+ "Salary Slip",
+ "Leave Ledger Entry",
+ "Leave Period",
+ "Leave Policy Assignment",
+ ]:
frappe.db.delete(dt)
frappe.set_user("Administrator")
@@ -702,59 +709,24 @@ class TestLeaveApplication(unittest.TestCase):
self.assertEqual(details.leave_balance, 30)
def test_earned_leaves_creation(self):
-
- frappe.db.sql("""delete from `tabLeave Period`""")
- frappe.db.sql("""delete from `tabLeave Policy Assignment`""")
- frappe.db.sql("""delete from `tabLeave Allocation`""")
- frappe.db.sql("""delete from `tabLeave Ledger Entry`""")
+ from erpnext.hr.utils import allocate_earned_leaves
leave_period = get_leave_period()
employee = get_employee()
leave_type = "Test Earned Leave Type"
- frappe.delete_doc_if_exists("Leave Type", "Test Earned Leave Type", force=1)
- frappe.get_doc(
- dict(
- leave_type_name=leave_type,
- doctype="Leave Type",
- is_earned_leave=1,
- earned_leave_frequency="Monthly",
- rounding=0.5,
- max_leaves_allowed=6,
- )
- ).insert()
+ make_policy_assignment(employee, leave_type, leave_period)
- leave_policy = frappe.get_doc(
- {
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}],
- }
- ).insert()
-
- data = {
- "assignment_based_on": "Leave Period",
- "leave_policy": leave_policy.name,
- "leave_period": leave_period.name,
- }
-
- leave_policy_assignments = create_assignment_for_multiple_employees(
- [employee.name], frappe._dict(data)
- )
-
- from erpnext.hr.utils import allocate_earned_leaves
-
- i = 0
- while i < 14:
+ for i in range(0, 14):
allocate_earned_leaves()
- i += 1
+
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
# validate earned leaves creation without maximum leaves
frappe.db.set_value("Leave Type", leave_type, "max_leaves_allowed", 0)
- i = 0
- while i < 6:
+
+ for i in range(0, 6):
allocate_earned_leaves()
- i += 1
+
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
# test to not consider current leave in leave balance while submitting
@@ -970,6 +942,54 @@ class TestLeaveApplication(unittest.TestCase):
self.assertEqual(leave_allocation["leaves_pending_approval"], 1)
self.assertEqual(leave_allocation["remaining_leaves"], 26)
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_get_earned_leave_details_for_dashboard(self):
+ from erpnext.hr.utils import allocate_earned_leaves
+
+ leave_period = get_leave_period()
+ employee = get_employee()
+ leave_type = "Test Earned Leave Type"
+ leave_policy_assignments = make_policy_assignment(employee, leave_type, leave_period)
+ allocation = frappe.db.get_value(
+ "Leave Allocation",
+ {"leave_policy_assignment": leave_policy_assignments[0]},
+ "name",
+ )
+ allocation = frappe.get_doc("Leave Allocation", allocation)
+ allocation.new_leaves_allocated = 2
+ allocation.save()
+
+ for i in range(0, 6):
+ allocate_earned_leaves()
+
+ first_sunday = get_first_sunday(self.holiday_list)
+ make_leave_application(
+ employee.name, add_days(first_sunday, 1), add_days(first_sunday, 1), leave_type
+ )
+
+ details = get_leave_details(employee.name, allocation.from_date)
+ leave_allocation = details["leave_allocation"][leave_type]
+ expected = {
+ "total_leaves": 2.0,
+ "expired_leaves": 0.0,
+ "leaves_taken": 1.0,
+ "leaves_pending_approval": 0.0,
+ "remaining_leaves": 1.0,
+ }
+ self.assertEqual(leave_allocation, expected)
+
+ details = get_leave_details(employee.name, getdate())
+ leave_allocation = details["leave_allocation"][leave_type]
+
+ expected = {
+ "total_leaves": 5.0,
+ "expired_leaves": 0.0,
+ "leaves_taken": 1.0,
+ "leaves_pending_approval": 0.0,
+ "remaining_leaves": 4.0,
+ }
+ self.assertEqual(leave_allocation, expected)
+
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_get_leave_allocation_records(self):
employee = get_employee()
@@ -1100,3 +1120,36 @@ def get_first_sunday(holiday_list, for_date=None):
)[0][0]
return first_sunday
+
+
+def make_policy_assignment(employee, leave_type, leave_period):
+ frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
+ frappe.get_doc(
+ dict(
+ leave_type_name=leave_type,
+ doctype="Leave Type",
+ is_earned_leave=1,
+ earned_leave_frequency="Monthly",
+ rounding=0.5,
+ max_leaves_allowed=6,
+ )
+ ).insert()
+
+ leave_policy = frappe.get_doc(
+ {
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}],
+ }
+ ).insert()
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name,
+ }
+
+ leave_policy_assignments = create_assignment_for_multiple_employees(
+ [employee.name], frappe._dict(data)
+ )
+ return leave_policy_assignments
diff --git a/erpnext/hr/doctype/leave_encashment/leave_encashment.py b/erpnext/hr/doctype/leave_encashment/leave_encashment.py
index 0f655e3e0fc..7c0f0db1975 100644
--- a/erpnext/hr/doctype/leave_encashment/leave_encashment.py
+++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.py
@@ -7,7 +7,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
-from erpnext.hr.doctype.leave_allocation.leave_allocation import get_unused_leaves
+from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry
from erpnext.hr.utils import set_employee_name, validate_active_employee
from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import (
@@ -107,7 +107,10 @@ class LeaveEncashment(Document):
self.leave_balance = (
allocation.total_leaves_allocated
- allocation.carry_forwarded_leaves_count
- - get_unused_leaves(self.employee, self.leave_type, allocation.from_date, self.encashment_date)
+ # adding this because the function returns a -ve number
+ + get_leaves_for_period(
+ self.employee, self.leave_type, allocation.from_date, self.encashment_date
+ )
)
encashable_days = self.leave_balance - frappe.db.get_value(
@@ -126,14 +129,25 @@ class LeaveEncashment(Document):
return True
def get_leave_allocation(self):
- leave_allocation = frappe.db.sql(
- """select name, to_date, total_leaves_allocated, carry_forwarded_leaves_count from `tabLeave Allocation` where '{0}'
- between from_date and to_date and docstatus=1 and leave_type='{1}'
- and employee= '{2}'""".format(
- self.encashment_date or getdate(nowdate()), self.leave_type, self.employee
- ),
- as_dict=1,
- ) # nosec
+ date = self.encashment_date or getdate()
+
+ LeaveAllocation = frappe.qb.DocType("Leave Allocation")
+ leave_allocation = (
+ frappe.qb.from_(LeaveAllocation)
+ .select(
+ LeaveAllocation.name,
+ LeaveAllocation.from_date,
+ LeaveAllocation.to_date,
+ LeaveAllocation.total_leaves_allocated,
+ LeaveAllocation.carry_forwarded_leaves_count,
+ )
+ .where(
+ ((LeaveAllocation.from_date <= date) & (date <= LeaveAllocation.to_date))
+ & (LeaveAllocation.docstatus == 1)
+ & (LeaveAllocation.leave_type == self.leave_type)
+ & (LeaveAllocation.employee == self.employee)
+ )
+ ).run(as_dict=True)
return leave_allocation[0] if leave_allocation else None
diff --git a/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py b/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py
index 83eb969feb0..d06b6a3764d 100644
--- a/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py
+++ b/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py
@@ -4,26 +4,42 @@
import unittest
import frappe
-from frappe.utils import add_months, today
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, get_year_ending, get_year_start, getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_period.test_leave_period import create_leave_period
from erpnext.hr.doctype.leave_policy.test_leave_policy import create_leave_policy
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
create_assignment_for_multiple_employees,
)
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
-test_dependencies = ["Leave Type"]
+test_records = frappe.get_test_records("Leave Type")
-class TestLeaveEncashment(unittest.TestCase):
+class TestLeaveEncashment(FrappeTestCase):
def setUp(self):
- frappe.db.sql("""delete from `tabLeave Period`""")
- frappe.db.sql("""delete from `tabLeave Policy Assignment`""")
- frappe.db.sql("""delete from `tabLeave Allocation`""")
- frappe.db.sql("""delete from `tabLeave Ledger Entry`""")
- frappe.db.sql("""delete from `tabAdditional Salary`""")
+ frappe.db.delete("Leave Period")
+ frappe.db.delete("Leave Policy Assignment")
+ frappe.db.delete("Leave Allocation")
+ frappe.db.delete("Leave Ledger Entry")
+ frappe.db.delete("Additional Salary")
+ frappe.db.delete("Leave Encashment")
+
+ if not frappe.db.exists("Leave Type", "_Test Leave Type Encashment"):
+ frappe.get_doc(test_records[2]).insert()
+
+ date = getdate()
+ year_start = getdate(get_year_start(date))
+ year_end = getdate(get_year_ending(date))
+
+ make_holiday_list("_Test Leave Encashment", year_start, year_end)
# create the leave policy
leave_policy = create_leave_policy(
@@ -32,9 +48,9 @@ class TestLeaveEncashment(unittest.TestCase):
leave_policy.submit()
# create employee, salary structure and assignment
- self.employee = make_employee("test_employee_encashment@example.com")
+ self.employee = make_employee("test_employee_encashment@example.com", company="_Test Company")
- self.leave_period = create_leave_period(add_months(today(), -3), add_months(today(), 3))
+ self.leave_period = create_leave_period(year_start, year_end, "_Test Company")
data = {
"assignment_based_on": "Leave Period",
@@ -53,27 +69,15 @@ class TestLeaveEncashment(unittest.TestCase):
other_details={"leave_encashment_amount_per_day": 50},
)
- def tearDown(self):
- for dt in [
- "Leave Period",
- "Leave Allocation",
- "Leave Ledger Entry",
- "Additional Salary",
- "Leave Encashment",
- "Salary Structure",
- "Leave Policy",
- ]:
- frappe.db.sql("delete from `tab%s`" % dt)
-
+ @set_holiday_list("_Test Leave Encashment", "_Test Company")
def test_leave_balance_value_and_amount(self):
- frappe.db.sql("""delete from `tabLeave Encashment`""")
leave_encashment = frappe.get_doc(
dict(
doctype="Leave Encashment",
employee=self.employee,
leave_type="_Test Leave Type Encashment",
leave_period=self.leave_period.name,
- payroll_date=today(),
+ encashment_date=self.leave_period.to_date,
currency="INR",
)
).insert()
@@ -88,15 +92,46 @@ class TestLeaveEncashment(unittest.TestCase):
add_sal = frappe.get_all("Additional Salary", filters={"ref_docname": leave_encashment.name})[0]
self.assertTrue(add_sal)
- def test_creation_of_leave_ledger_entry_on_submit(self):
- frappe.db.sql("""delete from `tabLeave Encashment`""")
+ @set_holiday_list("_Test Leave Encashment", "_Test Company")
+ def test_leave_balance_value_with_leaves_and_amount(self):
+ date = self.leave_period.from_date
+ leave_application = make_leave_application(
+ self.employee, date, add_days(date, 3), "_Test Leave Type Encashment"
+ )
+ leave_application.reload()
+
leave_encashment = frappe.get_doc(
dict(
doctype="Leave Encashment",
employee=self.employee,
leave_type="_Test Leave Type Encashment",
leave_period=self.leave_period.name,
- payroll_date=today(),
+ encashment_date=self.leave_period.to_date,
+ currency="INR",
+ )
+ ).insert()
+
+ self.assertEqual(leave_encashment.leave_balance, 10 - leave_application.total_leave_days)
+ # encashable days threshold is 5, total leaves are 6, so encashable days = 6-5 = 1
+ # with charge of 50 per day
+ self.assertEqual(leave_encashment.encashable_days, leave_encashment.leave_balance - 5)
+ self.assertEqual(leave_encashment.encashment_amount, 50)
+
+ leave_encashment.submit()
+
+ # assert links
+ add_sal = frappe.get_all("Additional Salary", filters={"ref_docname": leave_encashment.name})[0]
+ self.assertTrue(add_sal)
+
+ @set_holiday_list("_Test Leave Encashment", "_Test Company")
+ def test_creation_of_leave_ledger_entry_on_submit(self):
+ leave_encashment = frappe.get_doc(
+ dict(
+ doctype="Leave Encashment",
+ employee=self.employee,
+ leave_type="_Test Leave Type Encashment",
+ leave_period=self.leave_period.name,
+ encashment_date=self.leave_period.to_date,
currency="INR",
)
).insert()
diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py
index ce7e50f7f4a..82472dec418 100644
--- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py
+++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py
@@ -172,27 +172,24 @@ class StaffingPlan(Document):
@frappe.whitelist()
-def get_designation_counts(designation, company):
+def get_designation_counts(designation, company, job_opening=None):
if not designation:
return False
- employee_counts = {}
company_set = get_descendants_of("Company", company)
company_set.append(company)
- employee_counts["employee_count"] = frappe.db.get_value(
- "Employee",
- filters={"designation": designation, "status": "Active", "company": ("in", company_set)},
- fieldname=["count(name)"],
+ employee_count = frappe.db.count(
+ "Employee", {"designation": designation, "status": "Active", "company": ("in", company_set)}
)
- employee_counts["job_openings"] = frappe.db.get_value(
- "Job Opening",
- filters={"designation": designation, "status": "Open", "company": ("in", company_set)},
- fieldname=["count(name)"],
- )
+ filters = {"designation": designation, "status": "Open", "company": ("in", company_set)}
+ if job_opening:
+ filters["name"] = ("!=", job_opening)
- return employee_counts
+ job_openings = frappe.db.count("Job Opening", filters)
+
+ return {"employee_count": employee_count, "job_openings": job_openings}
@frappe.whitelist()
diff --git a/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py b/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py
index a3adbbd56a5..ac69c219791 100644
--- a/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py
+++ b/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py
@@ -85,13 +85,16 @@ def _set_up():
make_company()
-def make_company():
- if frappe.db.exists("Company", "_Test Company 10"):
+def make_company(name=None, abbr=None):
+ if not name:
+ name = "_Test Company 10"
+
+ if frappe.db.exists("Company", name):
return
company = frappe.new_doc("Company")
- company.company_name = "_Test Company 10"
- company.abbr = "_TC10"
+ company.company_name = name
+ company.abbr = abbr or "_TC10"
company.parent_company = "_Test Company 3"
company.default_currency = "INR"
company.country = "Pakistan"
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 269e4aae310..3f4e31b1b2b 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -55,6 +55,8 @@ def update_employee_work_history(employee, details, date=None, cancel=False):
new_data = getdate(new_data)
elif fieldtype == "Datetime" and new_data:
new_data = get_datetime(new_data)
+ elif fieldtype in ["Currency", "Float"] and new_data:
+ new_data = flt(new_data)
setattr(employee, item.fieldname, new_data)
if item.fieldname in ["department", "designation", "branch"]:
internal_work_history[item.fieldname] = item.new
@@ -439,20 +441,18 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining
return False
-def get_salary_assignment(employee, date):
- assignment = frappe.db.sql(
- """
- select * from `tabSalary Structure Assignment`
- where employee=%(employee)s
- and docstatus = 1
- and %(on_date)s >= from_date order by from_date desc limit 1""",
- {
- "employee": employee,
- "on_date": date,
- },
- as_dict=1,
+def get_salary_assignments(employee, payroll_period):
+ start_date, end_date = frappe.db.get_value(
+ "Payroll Period", payroll_period, ["start_date", "end_date"]
)
- return assignment[0] if assignment else None
+ assignments = frappe.db.get_all(
+ "Salary Structure Assignment",
+ filters={"employee": employee, "docstatus": 1, "from_date": ["between", (start_date, end_date)]},
+ fields=["*"],
+ order_by="from_date",
+ )
+
+ return assignments
def get_sal_slip_total_benefit_given(employee, payroll_period, component=False):
diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js
index 940a1bbc000..38328e69674 100644
--- a/erpnext/loan_management/doctype/loan/loan.js
+++ b/erpnext/loan_management/doctype/loan/loan.js
@@ -93,6 +93,12 @@ frappe.ui.form.on('Loan', {
frm.trigger("make_loan_refund");
},__('Create'));
}
+
+ if (frm.doc.status == "Loan Closure Requested" && frm.doc.is_term_loan && !frm.doc.is_secured_loan) {
+ frm.add_custom_button(__('Close Loan'), function() {
+ frm.trigger("close_unsecured_term_loan");
+ },__('Status'));
+ }
}
frm.trigger("toggle_fields");
},
@@ -174,6 +180,18 @@ frappe.ui.form.on('Loan', {
})
},
+ close_unsecured_term_loan: function(frm) {
+ frappe.call({
+ args: {
+ "loan": frm.doc.name
+ },
+ method: "erpnext.loan_management.doctype.loan.loan.close_unsecured_term_loan",
+ callback: function () {
+ frm.refresh();
+ }
+ })
+ },
+
request_loan_closure: function(frm) {
frappe.confirm(__("Do you really want to close this loan"),
function() {
diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py
index a0ef1b971cd..90ce0040583 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -60,18 +60,20 @@ class Loan(AccountsController):
)
def validate_cost_center(self):
- if not self.cost_center and self.rate_of_interest != 0:
+ if not self.cost_center and self.rate_of_interest != 0.0:
self.cost_center = frappe.db.get_value("Company", self.company, "cost_center")
- if not self.cost_center:
- frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0"))
+ if not self.cost_center:
+ frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0"))
def on_submit(self):
self.link_loan_security_pledge()
+ # Interest accrual for backdated term loans
+ self.accrue_loan_interest()
def on_cancel(self):
self.unlink_loan_security_pledge()
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def set_missing_fields(self):
if not self.company:
@@ -187,6 +189,16 @@ class Loan(AccountsController):
self.db_set("maximum_loan_amount", maximum_loan_value)
+ def accrue_loan_interest(self):
+ from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import (
+ process_loan_interest_accrual_for_term_loans,
+ )
+
+ if getdate(self.repayment_start_date) < getdate() and self.is_term_loan:
+ process_loan_interest_accrual_for_term_loans(
+ posting_date=getdate(), loan_type=self.loan_type, loan=self.name
+ )
+
def unlink_loan_security_pledge(self):
pledges = frappe.get_all("Loan Security Pledge", fields=["name"], filters={"loan": self.name})
pledge_list = [d.name for d in pledges]
@@ -330,6 +342,22 @@ def get_loan_application(loan_application):
return loan.as_dict()
+@frappe.whitelist()
+def close_unsecured_term_loan(loan):
+ loan_details = frappe.db.get_value(
+ "Loan", {"name": loan}, ["status", "is_term_loan", "is_secured_loan"], as_dict=1
+ )
+
+ if (
+ loan_details.status == "Loan Closure Requested"
+ and loan_details.is_term_loan
+ and not loan_details.is_secured_loan
+ ):
+ frappe.db.set_value("Loan", loan, "status", "Closed")
+ else:
+ frappe.throw(_("Cannot close this loan until full repayment"))
+
+
def close_loan(loan, total_amount_paid):
frappe.db.set_value("Loan", loan, "total_amount_paid", total_amount_paid)
frappe.db.set_value("Loan", loan, "status", "Closed")
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
index 10174e531a1..0c2042ba500 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
@@ -29,7 +29,7 @@ class LoanDisbursement(AccountsController):
def on_cancel(self):
self.set_status_and_amounts(cancel=1)
self.make_gl_entries(cancel=1)
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def set_missing_values(self):
if not self.disbursement_date:
diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
index 3a4c6513e45..0aeb4489184 100644
--- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
@@ -32,7 +32,7 @@ class LoanInterestAccrual(AccountsController):
self.update_is_accrued()
self.make_gl_entries(cancel=1)
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def update_is_accrued(self):
frappe.db.set_value("Repayment Schedule", self.repayment_schedule_name, "is_accrued", 0)
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 8614fcb9cdc..51f40d9a200 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -41,7 +41,7 @@ class LoanRepayment(AccountsController):
self.check_future_accruals()
self.update_repayment_schedule(cancel=1)
self.mark_as_unpaid()
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
self.make_gl_entries(cancel=1)
def set_missing_values(self, amounts):
@@ -448,8 +448,6 @@ class LoanRepayment(AccountsController):
"remarks": remarks,
"cost_center": self.cost_center,
"posting_date": getdate(self.posting_date),
- "party_type": self.applicant_type if self.repay_from_salary else "",
- "party": self.applicant if self.repay_from_salary else "",
}
)
)
diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
index e19fd15fc84..25aecf673bb 100644
--- a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
+++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
@@ -42,7 +42,7 @@ class LoanWriteOff(AccountsController):
def on_cancel(self):
self.update_outstanding_amount(cancel=1)
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
self.make_gl_entries(cancel=1)
def update_outstanding_amount(self, cancel=0):
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index 8a7634e24ec..ecad41fe7b8 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -81,7 +81,7 @@ frappe.ui.form.on("BOM", {
}
)
- if (!frm.doc.__islocal && frm.doc.docstatus<2) {
+ if (!frm.is_new() && frm.doc.docstatus<2) {
frm.add_custom_button(__("Update Cost"), function() {
frm.events.update_cost(frm, true);
});
@@ -93,6 +93,13 @@ frappe.ui.form.on("BOM", {
});
}
+ if (!frm.is_new() && !frm.doc.docstatus == 0) {
+ frm.add_custom_button(__("New Version"), function() {
+ let new_bom = frappe.model.copy_doc(frm.doc);
+ frappe.set_route("Form", "BOM", new_bom.name);
+ });
+ }
+
if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() {
frm.trigger("make_work_order");
@@ -499,15 +506,11 @@ cur_frm.cscript.qty = function(doc) {
cur_frm.cscript.rate = function(doc, cdt, cdn) {
var d = locals[cdt][cdn];
- var scrap_items = false;
-
- if(cdt == 'BOM Scrap Item') {
- scrap_items = true;
- }
+ const is_scrap_item = cdt == "BOM Scrap Item";
if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
- get_bom_material_detail(doc, cdt, cdn, scrap_items);
+ get_bom_material_detail(doc, cdt, cdn, is_scrap_item);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 220ce1dbd89..631548b3099 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import functools
import re
from collections import deque
from operator import itemgetter
-from typing import List
+from typing import Dict, List
import frappe
from frappe import _
@@ -22,6 +22,10 @@ from erpnext.stock.get_item_details import get_conversion_factor, get_price_list
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
+class BOMRecursionError(frappe.ValidationError):
+ pass
+
+
class BOMTree:
"""Full tree representation of a BOM"""
@@ -185,6 +189,7 @@ class BOM(WebsiteGenerator):
self.validate_transfer_against()
self.set_routing_operations()
self.validate_operations()
+ self.update_exploded_items(save=False)
self.calculate_cost()
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
@@ -251,9 +256,8 @@ class BOM(WebsiteGenerator):
for item in self.get("items"):
self.validate_bom_currency(item)
- item.bom_no = ""
- if not item.do_not_explode:
- item.bom_no = item.bom_no
+ if item.do_not_explode:
+ item.bom_no = ""
ret = self.get_bom_material_detail(
{
@@ -383,40 +387,14 @@ class BOM(WebsiteGenerator):
existing_bom_cost = self.total_cost
- for d in self.get("items"):
- if not d.item_code:
- continue
-
- rate = self.get_rm_rate(
- {
- "company": self.company,
- "item_code": d.item_code,
- "bom_no": d.bom_no,
- "qty": d.qty,
- "uom": d.uom,
- "stock_uom": d.stock_uom,
- "conversion_factor": d.conversion_factor,
- "sourced_by_supplier": d.sourced_by_supplier,
- }
- )
-
- if rate:
- d.rate = rate
- d.amount = flt(d.rate) * flt(d.qty)
- d.base_rate = flt(d.rate) * flt(self.conversion_rate)
- d.base_amount = flt(d.amount) * flt(self.conversion_rate)
-
- if save:
- d.db_update()
-
if self.docstatus == 1:
self.flags.ignore_validate_update_after_submit = True
- self.calculate_cost(update_hour_rate)
+
+ self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)
+
if save:
self.db_update()
- self.update_exploded_items(save=save)
-
# update parent BOMs
if self.total_cost != existing_bom_cost and update_parent:
parent_boms = frappe.db.sql_list(
@@ -555,35 +533,27 @@ class BOM(WebsiteGenerator):
"""Check whether recursion occurs in any bom"""
def _throw_error(bom_name):
- frappe.throw(_("BOM recursion: {0} cannot be parent or child of {0}").format(bom_name))
+ frappe.throw(
+ _("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name),
+ exc=BOMRecursionError,
+ )
bom_list = self.traverse_tree()
- child_items = (
- frappe.get_all(
- "BOM Item",
- fields=["bom_no", "item_code"],
- filters={"parent": ("in", bom_list), "parenttype": "BOM"},
- )
- or []
+ child_items = frappe.get_all(
+ "BOM Item",
+ fields=["bom_no", "item_code"],
+ filters={"parent": ("in", bom_list), "parenttype": "BOM"},
)
- child_bom = {d.bom_no for d in child_items}
- child_items_codes = {d.item_code for d in child_items}
+ for item in child_items:
+ if self.name == item.bom_no:
+ _throw_error(self.name)
+ if self.item == item.item_code and item.bom_no:
+ # Same item but with different BOM should not be allowed.
+ # Same item can appear recursively once as long as it doesn't have BOM.
+ _throw_error(item.bom_no)
- if self.name in child_bom:
- _throw_error(self.name)
-
- if self.item in child_items_codes:
- _throw_error(self.item)
-
- bom_nos = (
- frappe.get_all(
- "BOM Item", fields=["parent"], filters={"bom_no": self.name, "parenttype": "BOM"}
- )
- or []
- )
-
- if self.name in {d.parent for d in bom_nos}:
+ if self.name in {d.bom_no for d in self.items}:
_throw_error(self.name)
def traverse_tree(self, bom_list=None):
@@ -613,11 +583,15 @@ class BOM(WebsiteGenerator):
bom_list.reverse()
return bom_list
- def calculate_cost(self, update_hour_rate=False):
+ def calculate_cost(self, save_updates=False, update_hour_rate=False):
"""Calculate bom totals"""
self.calculate_op_cost(update_hour_rate)
- self.calculate_rm_cost()
- self.calculate_sm_cost()
+ self.calculate_rm_cost(save=save_updates)
+ self.calculate_sm_cost(save=save_updates)
+ if save_updates:
+ # not via doc event, table is not regenerated and needs updation
+ self.calculate_exploded_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
@@ -659,12 +633,26 @@ class BOM(WebsiteGenerator):
if update_hour_rate:
row.db_update()
- def calculate_rm_cost(self):
+ def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0
base_total_rm_cost = 0
for d in self.get("items"):
+ old_rate = d.rate
+ d.rate = self.get_rm_rate(
+ {
+ "company": self.company,
+ "item_code": d.item_code,
+ "bom_no": d.bom_no,
+ "qty": d.qty,
+ "uom": d.uom,
+ "stock_uom": d.stock_uom,
+ "conversion_factor": d.conversion_factor,
+ "sourced_by_supplier": d.sourced_by_supplier,
+ }
+ )
+
d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty"))
d.base_amount = d.amount * flt(self.conversion_rate)
@@ -674,11 +662,13 @@ class BOM(WebsiteGenerator):
total_rm_cost += d.amount
base_total_rm_cost += d.base_amount
+ if save and (old_rate != d.rate):
+ d.db_update()
self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_total_rm_cost
- def calculate_sm_cost(self):
+ def calculate_sm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0
base_total_sm_cost = 0
@@ -693,10 +683,45 @@ class BOM(WebsiteGenerator):
)
total_sm_cost += d.amount
base_total_sm_cost += d.base_amount
+ if save:
+ d.db_update()
self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost
+ def calculate_exploded_cost(self):
+ "Set exploded row cost from it's parent BOM."
+ rm_rate_map = self.get_rm_rate_map()
+
+ for row in self.get("exploded_items"):
+ old_rate = flt(row.rate)
+ row.rate = rm_rate_map.get(row.item_code)
+ row.amount = flt(row.stock_qty) * flt(row.rate)
+
+ if old_rate != row.rate:
+ # Only db_update if changed
+ row.db_update()
+
+ def get_rm_rate_map(self) -> Dict[str, float]:
+ "Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM."
+ rm_rate_map = {}
+
+ for item in self.get("items"):
+ if item.bom_no:
+ # Get Item-Rate from Subassembly BOM
+ explosion_items = frappe.get_all(
+ "BOM Explosion Item",
+ filters={"parent": item.bom_no},
+ fields=["item_code", "rate"],
+ order_by=None, # to avoid sort index creation at db level (granular change)
+ )
+ explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items}
+ rm_rate_map.update(explosion_item_rate)
+ else:
+ rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0)
+
+ return rm_rate_map
+
def update_exploded_items(self, save=True):
"""Update Flat BOM, following will be correct data"""
self.get_exploded_items()
@@ -907,44 +932,46 @@ def get_bom_item_rate(args, bom_doc):
return flt(rate)
-def get_valuation_rate(args):
- """Get weighted average of valuation rate from all warehouses"""
+def get_valuation_rate(data):
+ """
+ 1) Get average valuation rate from all warehouses
+ 2) If no value, get last valuation rate from SLE
+ 3) If no value, get valuation rate from Item
+ """
+ from frappe.query_builder.functions import Sum
- total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0
- item_bins = frappe.db.sql(
- """
- select
- bin.actual_qty, bin.stock_value
- from
- `tabBin` bin, `tabWarehouse` warehouse
- where
- bin.item_code=%(item)s
- and bin.warehouse = warehouse.name
- and warehouse.company=%(company)s""",
- {"item": args["item_code"], "company": args["company"]},
- as_dict=1,
- )
+ item_code, company = data.get("item_code"), data.get("company")
+ valuation_rate = 0.0
- for d in item_bins:
- total_qty += flt(d.actual_qty)
- total_value += flt(d.stock_value)
+ bin_table = frappe.qb.DocType("Bin")
+ wh_table = frappe.qb.DocType("Warehouse")
+ item_valuation = (
+ frappe.qb.from_(bin_table)
+ .join(wh_table)
+ .on(bin_table.warehouse == wh_table.name)
+ .select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate"))
+ .where((bin_table.item_code == item_code) & (wh_table.company == company))
+ ).run(as_dict=True)[0]
- if total_qty:
- valuation_rate = total_value / total_qty
+ valuation_rate = item_valuation.get("valuation_rate")
- if valuation_rate <= 0:
- last_valuation_rate = frappe.db.sql(
- """select valuation_rate
- from `tabStock Ledger Entry`
- where item_code = %s and valuation_rate > 0 and is_cancelled = 0
- order by posting_date desc, posting_time desc, creation desc limit 1""",
- args["item_code"],
- )
+ if (valuation_rate is not None) and valuation_rate <= 0:
+ # Explicit null value check. If None, Bins don't exist, neither does SLE
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ last_val_rate = (
+ frappe.qb.from_(sle)
+ .select(sle.valuation_rate)
+ .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
+ .orderby(sle.posting_date, order=frappe.qb.desc)
+ .orderby(sle.posting_time, order=frappe.qb.desc)
+ .orderby(sle.creation, order=frappe.qb.desc)
+ .limit(1)
+ ).run(as_dict=True)
- valuation_rate = flt(last_valuation_rate[0][0]) if last_valuation_rate else 0
+ valuation_rate = flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0
if not valuation_rate:
- valuation_rate = frappe.db.get_value("Item", args["item_code"], "valuation_rate")
+ valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate")
return flt(valuation_rate)
@@ -1130,39 +1157,6 @@ def get_children(parent=None, is_root=False, **filters):
return bom_items
-def get_boms_in_bottom_up_order(bom_no=None):
- def _get_parent(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,
- )
-
- count = 0
- bom_list = []
- if bom_no:
- bom_list.append(bom_no)
- else:
- # get all leaf BOMs
- 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, '')!='')"""
- )
-
- while count < len(bom_list):
- for child_bom in _get_parent(bom_list[count]):
- if child_bom not in bom_list:
- bom_list.append(child_bom)
- count += 1
-
- return bom_list
-
-
def add_additional_cost(stock_entry, work_order):
# Add non stock items cost in the additional cost
stock_entry.additional_costs = []
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index 62fc0724e03..182a20c6bb7 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -10,8 +10,10 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import cstr, flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
-from erpnext.manufacturing.doctype.bom.bom import item_query, make_variant_bom
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom
+from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
+ update_cost_in_all_boms_in_test,
+)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
@@ -69,26 +71,31 @@ class TestBOM(FrappeTestCase):
def test_update_bom_cost_in_all_boms(self):
# get current rate for '_Test Item 2'
- rm_rate = frappe.db.sql(
- """select rate from `tabBOM Item`
- where parent='BOM-_Test Item Home Desktop Manufactured-001'
- and item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'"""
+ bom_rates = frappe.db.get_values(
+ "BOM Item",
+ {
+ "parent": "BOM-_Test Item Home Desktop Manufactured-001",
+ "item_code": "_Test Item 2",
+ "docstatus": 1,
+ },
+ fieldname=["rate", "base_rate"],
+ as_dict=True,
)
- rm_rate = rm_rate[0][0] if rm_rate else 0
+ rm_base_rate = bom_rates[0].get("base_rate") if bom_rates else 0
# Reset item valuation rate
- reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_rate + 10)
+ reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_base_rate + 10)
# update cost of all BOMs based on latest valuation rate
- update_cost()
+ update_cost_in_all_boms_in_test()
# check if new valuation rate updated in all BOMs
for d in frappe.db.sql(
- """select rate from `tabBOM Item`
+ """select base_rate from `tabBOM Item`
where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""",
as_dict=1,
):
- self.assertEqual(d.rate, rm_rate + 10)
+ self.assertEqual(d.base_rate, rm_base_rate + 10)
def test_bom_cost(self):
bom = frappe.copy_doc(test_records[2])
@@ -324,43 +331,36 @@ class TestBOM(FrappeTestCase):
def test_bom_recursion_1st_level(self):
"""BOM should not allow BOM item again in child"""
- item_code = "_Test BOM Recursion"
- make_item(item_code, {"is_stock_item": 1})
+ item_code = make_item(properties={"is_stock_item": 1}).name
bom = frappe.new_doc("BOM")
bom.item = item_code
bom.append("items", frappe._dict(item_code=item_code))
- with self.assertRaises(frappe.ValidationError) as err:
+ bom.save()
+ with self.assertRaises(BOMRecursionError):
+ bom.items[0].bom_no = bom.name
bom.save()
- self.assertTrue("recursion" in str(err.exception).lower())
- frappe.delete_doc("BOM", bom.name, ignore_missing=True)
-
def test_bom_recursion_transitive(self):
- item1 = "_Test BOM Recursion"
- item2 = "_Test BOM Recursion 2"
- make_item(item1, {"is_stock_item": 1})
- make_item(item2, {"is_stock_item": 1})
+ item1 = make_item(properties={"is_stock_item": 1}).name
+ item2 = make_item(properties={"is_stock_item": 1}).name
bom1 = frappe.new_doc("BOM")
bom1.item = item1
bom1.append("items", frappe._dict(item_code=item2))
bom1.save()
- bom1.submit()
bom2 = frappe.new_doc("BOM")
bom2.item = item2
bom2.append("items", frappe._dict(item_code=item1))
+ bom2.save()
- with self.assertRaises(frappe.ValidationError) as err:
+ bom2.items[0].bom_no = bom1.name
+ bom1.items[0].bom_no = bom2.name
+
+ with self.assertRaises(BOMRecursionError):
+ bom1.save()
bom2.save()
- bom2.submit()
-
- self.assertTrue("recursion" in str(err.exception).lower())
-
- bom1.cancel()
- frappe.delete_doc("BOM", bom1.name, ignore_missing=True, force=True)
- frappe.delete_doc("BOM", bom2.name, ignore_missing=True, force=True)
def test_bom_with_process_loss_item(self):
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json
index 25730f9b9f4..507d319b515 100644
--- a/erpnext/manufacturing/doctype/bom/test_records.json
+++ b/erpnext/manufacturing/doctype/bom/test_records.json
@@ -32,6 +32,7 @@
"is_active": 1,
"is_default": 1,
"item": "_Test Item Home Desktop Manufactured",
+ "company": "_Test Company",
"quantity": 1.0
},
{
diff --git a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json
index f01d856e72a..9b1db63494b 100644
--- a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json
+++ b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json
@@ -169,13 +169,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-10-08 16:21:29.386212",
+ "modified": "2022-05-27 13:42:23.305455",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Explosion Item",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json
index 3406215cbbb..0a8ae7b4a73 100644
--- a/erpnext/manufacturing/doctype/bom_item/bom_item.json
+++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json
@@ -33,7 +33,6 @@
"amount",
"base_amount",
"section_break_18",
- "scrap",
"qty_consumed_per_unit",
"section_break_27",
"has_variants",
@@ -223,15 +222,6 @@
"fieldname": "section_break_18",
"fieldtype": "Section Break"
},
- {
- "columns": 1,
- "fieldname": "scrap",
- "fieldtype": "Float",
- "label": "Scrap %",
- "oldfieldname": "scrap",
- "oldfieldtype": "Currency",
- "print_hide": 1
- },
{
"fieldname": "qty_consumed_per_unit",
"fieldtype": "Float",
@@ -298,7 +288,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-01-24 16:57:57.020232",
+ "modified": "2022-05-19 02:32:43.785470",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Item",
diff --git a/erpnext/setup/doctype/naming_series/__init__.py b/erpnext/manufacturing/doctype/bom_update_batch/__init__.py
similarity index 100%
rename from erpnext/setup/doctype/naming_series/__init__.py
rename to erpnext/manufacturing/doctype/bom_update_batch/__init__.py
diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json
new file mode 100644
index 00000000000..83b54d326cb
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json
@@ -0,0 +1,55 @@
+{
+ "actions": [],
+ "autoname": "autoincrement",
+ "creation": "2022-05-31 17:34:39.825537",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "level",
+ "batch_no",
+ "boms_updated",
+ "status"
+ ],
+ "fields": [
+ {
+ "fieldname": "level",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Level"
+ },
+ {
+ "fieldname": "batch_no",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Batch No."
+ },
+ {
+ "fieldname": "boms_updated",
+ "fieldtype": "Long Text",
+ "hidden": 1,
+ "in_list_view": 1,
+ "label": "BOMs Updated"
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Status",
+ "options": "Pending\nCompleted",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2022-06-06 14:50:35.161062",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "BOM Update Batch",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py
new file mode 100644
index 00000000000..f952e435e67
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BOMUpdateBatch(Document):
+ pass
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
index 98c1acb71ce..c32e383b08a 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
@@ -13,6 +13,10 @@
"update_type",
"status",
"error_log",
+ "progress_section",
+ "current_level",
+ "processed_boms",
+ "bom_batches",
"amended_from"
],
"fields": [
@@ -63,13 +67,36 @@
"fieldtype": "Link",
"label": "Error Log",
"options": "Error Log"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "eval: doc.update_type == \"Update Cost\"",
+ "fieldname": "progress_section",
+ "fieldtype": "Section Break",
+ "label": "Progress"
+ },
+ {
+ "fieldname": "processed_boms",
+ "fieldtype": "Long Text",
+ "hidden": 1,
+ "label": "Processed BOMs"
+ },
+ {
+ "fieldname": "bom_batches",
+ "fieldtype": "Table",
+ "options": "BOM Update Batch"
+ },
+ {
+ "fieldname": "current_level",
+ "fieldtype": "Int",
+ "label": "Current Level"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-03-31 12:51:44.885102",
+ "modified": "2022-06-06 15:15:23.883251",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Update Log",
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
index c0770fac90a..9c9c24044aa 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
@@ -1,13 +1,20 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-from typing import Dict, List, Literal, Optional
+import json
+from typing import Any, Dict, List, Optional, Tuple, Union
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cstr, flt
+from frappe.utils import cint, cstr
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import (
+ get_leaf_boms,
+ get_next_higher_level_boms,
+ handle_exception,
+ replace_bom,
+ set_values_in_log,
+)
class BOMMissingError(frappe.ValidationError):
@@ -20,6 +27,8 @@ class BOMUpdateLog(Document):
self.validate_boms_are_specified()
self.validate_same_bom()
self.validate_bom_items()
+ else:
+ self.validate_bom_cost_update_in_progress()
self.status = "Queued"
@@ -42,123 +51,184 @@ class BOMUpdateLog(Document):
if current_bom_item != new_bom_item:
frappe.throw(_("The selected BOMs are not for the same item"))
- def on_submit(self):
- if frappe.flags.in_test:
- return
+ def validate_bom_cost_update_in_progress(self):
+ "If another Cost Updation Log is still in progress, dont make new ones."
+ wip_log = frappe.get_all(
+ "BOM Update Log",
+ {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]},
+ limit_page_length=1,
+ )
+ if wip_log:
+ log_link = frappe.utils.get_link_to_form("BOM Update Log", wip_log[0].name)
+ frappe.throw(
+ _("BOM Updation already in progress. Please wait until {0} is complete.").format(log_link),
+ title=_("Note"),
+ )
+
+ def on_submit(self):
if self.update_type == "Replace BOM":
boms = {"current_bom": self.current_bom, "new_bom": self.new_bom}
frappe.enqueue(
- method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_replace_bom_job",
doc=self,
boms=boms,
timeout=40000,
+ now=frappe.flags.in_test,
)
else:
- frappe.enqueue(
- method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
- doc=self,
- update_type="Update Cost",
- timeout=40000,
- )
+ process_boms_cost_level_wise(self)
-def replace_bom(boms: Dict) -> None:
- """Replace current BOM with new BOM in parent BOMs."""
- current_bom = boms.get("current_bom")
- new_bom = boms.get("new_bom")
-
- unit_cost = get_new_bom_unit_cost(new_bom)
- update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
-
- frappe.cache().delete_key("bom_children")
- parent_boms = get_parent_boms(new_bom)
-
- for bom in parent_boms:
- bom_obj = frappe.get_doc("BOM", bom)
- # this is only used for versioning and we do not want
- # to make separate db calls by using load_doc_before_save
- # which proves to be expensive while doing bulk replace
- bom_obj._doc_before_save = bom_obj
- bom_obj.update_exploded_items()
- bom_obj.calculate_cost()
- bom_obj.update_parent_cost()
- bom_obj.db_update()
- if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version:
- bom_obj.save_version()
-
-
-def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
- bom_item = frappe.qb.DocType("BOM Item")
- (
- frappe.qb.update(bom_item)
- .set(bom_item.bom_no, new_bom)
- .set(bom_item.rate, unit_cost)
- .set(bom_item.amount, (bom_item.stock_qty * unit_cost))
- .where(
- (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
- )
- ).run()
-
-
-def get_parent_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
- bom_list = bom_list or []
- bom_item = frappe.qb.DocType("BOM Item")
-
- parents = (
- frappe.qb.from_(bom_item)
- .select(bom_item.parent)
- .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
- .run(as_dict=True)
- )
-
- for d in parents:
- if new_bom == d.parent:
- frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
-
- bom_list.append(d.parent)
- get_parent_boms(d.parent, bom_list)
-
- return list(set(bom_list))
-
-
-def get_new_bom_unit_cost(new_bom: str) -> float:
- bom = frappe.qb.DocType("BOM")
- new_bom_unitcost = (
- frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run()
- )
-
- return flt(new_bom_unitcost[0][0])
-
-
-def run_bom_job(
+def run_replace_bom_job(
doc: "BOMUpdateLog",
boms: Optional[Dict[str, str]] = None,
- update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM",
) -> None:
try:
doc.db_set("status", "In Progress")
+
if not frappe.flags.in_test:
frappe.db.commit()
frappe.db.auto_commit_on_many_writes = 1
-
boms = frappe._dict(boms or {})
-
- if update_type == "Replace BOM":
- replace_bom(boms)
- else:
- update_cost()
+ replace_bom(boms, doc.name)
doc.db_set("status", "Completed")
-
except Exception:
- frappe.db.rollback()
- error_log = doc.log_error("BOM Update Tool Error")
-
- doc.db_set("status", "Failed")
- doc.db_set("error_log", error_log.name)
-
+ handle_exception(doc)
finally:
frappe.db.auto_commit_on_many_writes = 0
- frappe.db.commit() # nosemgrep
+
+ if not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def process_boms_cost_level_wise(
+ update_doc: "BOMUpdateLog", parent_boms: List[str] = None
+) -> Union[None, Tuple]:
+ "Queue jobs at the start of new BOM Level in 'Update Cost' Jobs."
+
+ current_boms = {}
+ values = {}
+
+ if update_doc.status == "Queued":
+ # First level yet to process. On Submit.
+ current_level = 0
+ current_boms = get_leaf_boms()
+ values = {
+ "processed_boms": json.dumps({}),
+ "status": "In Progress",
+ "current_level": current_level,
+ }
+ else:
+ # Resume next level. via Cron Job.
+ if not parent_boms:
+ return
+
+ current_level = cint(update_doc.current_level) + 1
+
+ # Process the next level BOMs. Stage parents as current BOMs.
+ current_boms = parent_boms.copy()
+ values = {"current_level": current_level}
+
+ set_values_in_log(update_doc.name, values, commit=True)
+ queue_bom_cost_jobs(current_boms, update_doc, current_level)
+
+
+def queue_bom_cost_jobs(
+ current_boms_list: List[str], update_doc: "BOMUpdateLog", current_level: int
+) -> None:
+ "Queue batches of 20k BOMs of the same level to process parallelly"
+ batch_no = 0
+
+ while current_boms_list:
+ batch_no += 1
+ batch_size = 20_000
+ boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs
+
+ # update list to exclude 20K (queued) BOMs
+ current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else []
+
+ batch_row = update_doc.append(
+ "bom_batches", {"level": current_level, "batch_no": batch_no, "status": "Pending"}
+ )
+ batch_row.db_insert()
+
+ frappe.enqueue(
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level",
+ doc=update_doc,
+ bom_list=boms_to_process,
+ batch_name=batch_row.name,
+ queue="long",
+ now=frappe.flags.in_test,
+ )
+
+
+def resume_bom_cost_update_jobs():
+ """
+ 1. Checks for In Progress BOM Update Log.
+ 2. Checks if this job has completed the _current level_.
+ 3. If current level is complete, get parent BOMs and start next level.
+ 4. If no parents, mark as Complete.
+ 5. If current level is WIP, skip the Log.
+
+ Called every 5 minutes via Cron job.
+ """
+
+ in_progress_logs = frappe.db.get_all(
+ "BOM Update Log",
+ {"update_type": "Update Cost", "status": "In Progress"},
+ ["name", "processed_boms", "current_level"],
+ )
+ if not in_progress_logs:
+ return
+
+ for log in in_progress_logs:
+ # check if all log batches of current level are processed
+ bom_batches = frappe.db.get_all(
+ "BOM Update Batch",
+ {"parent": log.name, "level": log.current_level},
+ ["name", "boms_updated", "status"],
+ )
+ incomplete_level = any(row.get("status") == "Pending" for row in bom_batches)
+ if not bom_batches or incomplete_level:
+ continue
+
+ # Prep parent BOMs & updated processed BOMs for next level
+ current_boms, processed_boms = get_processed_current_boms(log, bom_batches)
+ parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms)
+
+ # Unset processed BOMs if log is complete, it is used for next level BOMs
+ set_values_in_log(
+ log.name,
+ values={
+ "processed_boms": json.dumps([] if not parent_boms else processed_boms),
+ "status": "Completed" if not parent_boms else "In Progress",
+ },
+ commit=True,
+ )
+
+ if parent_boms: # there is a next level to process
+ process_boms_cost_level_wise(
+ update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms
+ )
+
+
+def get_processed_current_boms(
+ log: Dict[str, Any], bom_batches: Dict[str, Any]
+) -> Tuple[List[str], Dict[str, Any]]:
+ """
+ Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field
+ and into current boms list.
+ """
+ processed_boms = json.loads(log.processed_boms) if log.processed_boms else {}
+ current_boms = []
+
+ for row in bom_batches:
+ boms_updated = json.loads(row.boms_updated)
+ current_boms.extend(boms_updated)
+ boms_updated_dict = {bom: True for bom in boms_updated}
+ processed_boms.update(boms_updated_dict)
+
+ return current_boms, processed_boms
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py
new file mode 100644
index 00000000000..af115e3e421
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py
@@ -0,0 +1,225 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import copy
+import json
+from collections import defaultdict
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
+
+if TYPE_CHECKING:
+ from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog
+
+import frappe
+from frappe import _
+
+
+def replace_bom(boms: Dict, log_name: str) -> None:
+ "Replace current BOM with new BOM in parent BOMs."
+
+ current_bom = boms.get("current_bom")
+ new_bom = boms.get("new_bom")
+
+ unit_cost = get_bom_unit_cost(new_bom)
+ update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
+
+ frappe.cache().delete_key("bom_children")
+ parent_boms = get_ancestor_boms(new_bom)
+
+ for bom in parent_boms:
+ bom_obj = frappe.get_doc("BOM", bom)
+ # this is only used for versioning and we do not want
+ # to make separate db calls by using load_doc_before_save
+ # which proves to be expensive while doing bulk replace
+ bom_obj._doc_before_save = copy.deepcopy(bom_obj)
+ bom_obj.update_exploded_items()
+ bom_obj.calculate_cost()
+ bom_obj.update_parent_cost()
+ bom_obj.db_update()
+ bom_obj.flags.updater_reference = {
+ "doctype": "BOM Update Log",
+ "docname": log_name,
+ "label": _("via BOM Update Tool"),
+ }
+ bom_obj.save_version()
+
+
+def update_cost_in_level(
+ doc: "BOMUpdateLog", bom_list: List[str], batch_name: Union[int, str]
+) -> None:
+ "Updates Cost for BOMs within a given level. Runs via background jobs."
+
+ try:
+ status = frappe.db.get_value("BOM Update Log", doc.name, "status")
+ if status == "Failed":
+ return
+
+ update_cost_in_boms(bom_list=bom_list) # main updation logic
+
+ bom_batch = frappe.qb.DocType("BOM Update Batch")
+ (
+ frappe.qb.update(bom_batch)
+ .set(bom_batch.boms_updated, json.dumps(bom_list))
+ .set(bom_batch.status, "Completed")
+ .where(bom_batch.name == batch_name)
+ ).run()
+ except Exception:
+ handle_exception(doc)
+ finally:
+ if not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
+ "Recursively get all ancestors of BOM."
+
+ bom_list = bom_list or []
+ bom_item = frappe.qb.DocType("BOM Item")
+
+ parents = (
+ frappe.qb.from_(bom_item)
+ .select(bom_item.parent)
+ .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
+ .run(as_dict=True)
+ )
+
+ for d in parents:
+ if new_bom == d.parent:
+ frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
+
+ bom_list.append(d.parent)
+ get_ancestor_boms(d.parent, bom_list)
+
+ return list(set(bom_list))
+
+
+def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
+ bom_item = frappe.qb.DocType("BOM Item")
+ (
+ frappe.qb.update(bom_item)
+ .set(bom_item.bom_no, new_bom)
+ .set(bom_item.rate, unit_cost)
+ .set(bom_item.amount, (bom_item.stock_qty * unit_cost))
+ .where(
+ (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
+ )
+ ).run()
+
+
+def get_bom_unit_cost(bom_name: str) -> float:
+ bom = frappe.qb.DocType("BOM")
+ new_bom_unitcost = (
+ frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == bom_name).run()
+ )
+
+ return frappe.utils.flt(new_bom_unitcost[0][0])
+
+
+def update_cost_in_boms(bom_list: List[str]) -> None:
+ "Updates cost in given BOMs. Returns current and total updated BOMs."
+
+ for index, bom in enumerate(bom_list):
+ bom_doc = frappe.get_doc("BOM", bom, for_update=True)
+ bom_doc.calculate_cost(save_updates=True, update_hour_rate=True)
+ bom_doc.db_update()
+
+ if (index % 50 == 0) and not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def get_next_higher_level_boms(
+ child_boms: List[str], processed_boms: Dict[str, bool]
+) -> List[str]:
+ "Generate immediate higher level dependants with no unresolved dependencies (children)."
+
+ def _all_children_are_processed(parent_bom):
+ child_boms = dependency_map.get(parent_bom)
+ return all(processed_boms.get(bom) for bom in child_boms)
+
+ dependants_map, dependency_map = _generate_dependence_map()
+
+ dependants = []
+ for bom in child_boms:
+ # generate list of immediate dependants
+ parents = dependants_map.get(bom) or []
+ dependants.extend(parents)
+
+ dependants = set(dependants) # remove duplicates
+ resolved_dependants = set()
+
+ # consider only if children are all resolved
+ for parent_bom in dependants:
+ if _all_children_are_processed(parent_bom):
+ resolved_dependants.add(parent_bom)
+
+ return list(resolved_dependants)
+
+
+def get_leaf_boms() -> List[str]:
+ "Get BOMs that have no dependencies."
+
+ return 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, '')!='')"""
+ )
+
+
+def _generate_dependence_map() -> defaultdict:
+ """
+ Generate maps such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }.
+ Here BOM-1 is the leaf/lower level node/dependency.
+ The list contains one level higher nodes/dependants that depend on BOM-1.
+
+ Generate and return the reverse as well.
+ """
+
+ bom = frappe.qb.DocType("BOM")
+ bom_item = frappe.qb.DocType("BOM Item")
+
+ bom_items = (
+ frappe.qb.from_(bom_item)
+ .join(bom)
+ .on(bom_item.parent == bom.name)
+ .select(bom_item.bom_no, bom_item.parent)
+ .where(
+ (bom_item.bom_no.isnotnull())
+ & (bom_item.bom_no != "")
+ & (bom.docstatus == 1)
+ & (bom.is_active == 1)
+ & (bom_item.parenttype == "BOM")
+ )
+ ).run(as_dict=True)
+
+ child_parent_map = defaultdict(list)
+ parent_child_map = defaultdict(list)
+ for row in bom_items:
+ child_parent_map[row.bom_no].append(row.parent)
+ parent_child_map[row.parent].append(row.bom_no)
+
+ return child_parent_map, parent_child_map
+
+
+def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = False) -> None:
+ "Update BOM Update Log record."
+
+ if not values:
+ return
+
+ bom_update_log = frappe.qb.DocType("BOM Update Log")
+ query = frappe.qb.update(bom_update_log).where(bom_update_log.name == log_name)
+
+ for key, value in values.items():
+ query = query.set(key, value)
+ query.run()
+
+ if commit and not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def handle_exception(doc: "BOMUpdateLog") -> None:
+ "Rolls back and fails BOM Update Log."
+
+ frappe.db.rollback()
+ error_log = doc.log_error("BOM Update Tool Error")
+ set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name})
diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
index 47efea961b4..b38fc8976b2 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
+++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
@@ -6,9 +6,12 @@ from frappe.tests.utils import FrappeTestCase
from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import (
BOMMissingError,
- run_bom_job,
+ resume_bom_cost_update_jobs,
+)
+from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import (
+ enqueue_replace_bom,
+ enqueue_update_cost,
)
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
test_records = frappe.get_test_records("BOM")
@@ -31,17 +34,12 @@ class TestBOMUpdateLog(FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
- if self._testMethodName == "test_bom_update_log_completion":
- # clear logs and delete BOM created via setUp
- frappe.db.delete("BOM Update Log")
- self.new_bom_doc.cancel()
- self.new_bom_doc.delete()
-
- # explicitly commit and restore to original state
- frappe.db.commit() # nosemgrep
-
def test_bom_update_log_validate(self):
- "Test if BOM presence is validated."
+ """
+ 1) Test if BOM presence is validated.
+ 2) Test if same BOMs are validated.
+ 3) Test of non-existent BOM is validated.
+ """
with self.assertRaises(BOMMissingError):
enqueue_replace_bom(boms={})
@@ -52,45 +50,22 @@ class TestBOMUpdateLog(FrappeTestCase):
with self.assertRaises(frappe.ValidationError):
enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM"))
- def test_bom_update_log_queueing(self):
- "Test if BOM Update Log is created and queued."
-
- log = enqueue_replace_bom(
- boms=self.boms,
- )
-
- self.assertEqual(log.docstatus, 1)
- self.assertEqual(log.status, "Queued")
-
def test_bom_update_log_completion(self):
"Test if BOM Update Log handles job completion correctly."
- log = enqueue_replace_bom(
- boms=self.boms,
- )
-
- # Explicitly commits log, new bom (setUp) and replacement impact.
- # Is run via background jobs IRL
- run_bom_job(
- doc=log,
- boms=self.boms,
- update_type="Replace BOM",
- )
+ log = enqueue_replace_bom(boms=self.boms)
log.reload()
-
self.assertEqual(log.status, "Completed")
- # teardown (undo replace impact) due to commit
- boms = frappe._dict(
- current_bom=self.boms.new_bom,
- new_bom=self.boms.current_bom,
- )
- log2 = enqueue_replace_bom(
- boms=self.boms,
- )
- run_bom_job( # Explicitly commits
- doc=log2,
- boms=boms,
- update_type="Replace BOM",
- )
- self.assertEqual(log2.status, "Completed")
+
+def update_cost_in_all_boms_in_test():
+ """
+ Utility to run 'Update Cost' job in tests without Cron job until fully complete.
+ """
+ log = enqueue_update_cost() # create BOM Update Log
+
+ while log.status != "Completed":
+ resume_bom_cost_update_jobs() # run cron job until complete
+ log.reload()
+
+ return log
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
index b0e7da12017..d16fcd08326 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
@@ -10,8 +10,6 @@ if TYPE_CHECKING:
import frappe
from frappe.model.document import Document
-from erpnext.manufacturing.doctype.bom.bom import get_boms_in_bottom_up_order
-
class BOMUpdateTool(Document):
pass
@@ -40,14 +38,13 @@ def enqueue_update_cost() -> "BOMUpdateLog":
def auto_update_latest_price_in_all_boms() -> None:
"""Called via hooks.py."""
if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"):
- update_cost()
-
-
-def update_cost() -> None:
- """Updates Cost for all BOMs from bottom to top."""
- bom_list = get_boms_in_bottom_up_order()
- for bom in bom_list:
- frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True)
+ wip_log = frappe.get_all(
+ "BOM Update Log",
+ {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]},
+ limit_page_length=1,
+ )
+ if not wip_log:
+ create_bom_update_log(update_type="Update Cost")
def create_bom_update_log(
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
index fae72a0f6f7..5dd557f8ab1 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
@@ -1,11 +1,13 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
-from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_bom
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
+ update_cost_in_all_boms_in_test,
+)
+from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item
@@ -15,6 +17,9 @@ test_records = frappe.get_test_records("BOM")
class TestBOMUpdateTool(FrappeTestCase):
"Test major functions run via BOM Update Tool."
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001"
@@ -23,15 +28,10 @@ class TestBOMUpdateTool(FrappeTestCase):
bom_doc.insert()
boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name)
- replace_bom(boms)
+ enqueue_replace_bom(boms=boms)
- self.assertFalse(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", current_bom))
- self.assertTrue(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", bom_doc.name))
-
- # reverse, as it affects other testcases
- boms.current_bom = bom_doc.name
- boms.new_bom = current_bom
- replace_bom(boms)
+ self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1}))
+ self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1}))
def test_bom_cost(self):
for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]:
@@ -52,13 +52,13 @@ class TestBOMUpdateTool(FrappeTestCase):
self.assertEqual(doc.total_cost, 200)
frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 200)
- update_cost()
+ update_cost_in_all_boms_in_test()
doc.load_from_db()
self.assertEqual(doc.total_cost, 300)
frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 100)
- update_cost()
+ update_cost_in_all_boms_in_test()
doc.load_from_db()
self.assertEqual(doc.total_cost, 200)
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index a98fc948683..ed45106634d 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -42,6 +42,10 @@ class JobCardCancelError(frappe.ValidationError):
pass
+class JobCardOverTransferError(frappe.ValidationError):
+ pass
+
+
class JobCard(Document):
def onload(self):
excess_transfer = frappe.db.get_single_value(
@@ -522,23 +526,50 @@ class JobCard(Document):
},
)
- def set_transferred_qty_in_job_card(self, ste_doc):
+ def set_transferred_qty_in_job_card_item(self, ste_doc):
+ from frappe.query_builder.functions import Sum
+
+ def _validate_over_transfer(row, transferred_qty):
+ "Block over transfer of items if not allowed in settings."
+ required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty")
+ is_excess = flt(transferred_qty) > flt(required_qty)
+ if is_excess:
+ frappe.throw(
+ _(
+ "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
+ ).format(
+ row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
+ ),
+ title=_("Excess Transfer"),
+ exc=JobCardOverTransferError,
+ )
+
for row in ste_doc.items:
if not row.job_card_item:
continue
- qty = frappe.db.sql(
- """ SELECT SUM(qty) from `tabStock Entry Detail` sed, `tabStock Entry` se
- WHERE sed.job_card_item = %s and se.docstatus = 1 and sed.parent = se.name and
- se.purpose = 'Material Transfer for Manufacture'
- """,
- (row.job_card_item),
- )[0][0]
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ se = frappe.qb.DocType("Stock Entry")
+ transferred_qty = (
+ frappe.qb.from_(sed)
+ .join(se)
+ .on(sed.parent == se.name)
+ .select(Sum(sed.qty))
+ .where(
+ (sed.job_card_item == row.job_card_item)
+ & (se.docstatus == 1)
+ & (se.purpose == "Material Transfer for Manufacture")
+ )
+ ).run()[0][0]
- frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(qty))
+ allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")
+ if not allow_excess:
+ _validate_over_transfer(row, transferred_qty)
+
+ frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
def set_transferred_qty(self, update_status=False):
- "Set total FG Qty for which RM was transferred."
+ "Set total FG Qty in Job Card for which RM was transferred."
if not self.items:
self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0
@@ -590,19 +621,20 @@ class JobCard(Document):
self.set_status(update_status)
def set_status(self, update_status=False):
- if self.status == "On Hold":
+ if self.status == "On Hold" and self.docstatus == 0:
return
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
- if self.for_quantity <= self.transferred_qty:
- self.status = "Material Transferred"
+ if self.docstatus < 2:
+ if self.for_quantity <= self.transferred_qty:
+ self.status = "Material Transferred"
- if self.time_logs:
- self.status = "Work In Progress"
+ if self.time_logs:
+ self.status = "Work In Progress"
- if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items):
- self.status = "Completed"
+ if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items):
+ self.status = "Completed"
if update_status:
self.db_set("status", self.status)
@@ -866,6 +898,7 @@ def make_corrective_job_card(source_name, operation=None, for_operation=None, ta
target.set("time_logs", [])
target.set("employee", [])
target.set("items", [])
+ target.set("sub_operations", [])
target.set_sub_operations()
target.get_required_items()
target.validate_time_logs()
diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js
index 7f60bdc6d92..5d883bf9fa7 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card_list.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js
@@ -1,16 +1,17 @@
frappe.listview_settings['Job Card'] = {
has_indicator_for_draft: true,
+
get_indicator: function(doc) {
- if (doc.status === "Work In Progress") {
- return [__("Work In Progress"), "orange", "status,=,Work In Progress"];
- } else if (doc.status === "Completed") {
- return [__("Completed"), "green", "status,=,Completed"];
- } else if (doc.docstatus == 2) {
- return [__("Cancelled"), "red", "status,=,Cancelled"];
- } else if (doc.status === "Material Transferred") {
- return [__('Material Transferred'), "blue", "status,=,Material Transferred"];
- } else {
- return [__("Open"), "red", "status,=,Open"];
- }
+ const status_colors = {
+ "Work In Progress": "orange",
+ "Completed": "green",
+ "Cancelled": "red",
+ "Material Transferred": "blue",
+ "Open": "red",
+ };
+ const status = doc.status || "Open";
+ const color = status_colors[status] || "blue";
+
+ return [__(status), color, `status,=,${status}`];
}
};
diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index 4647ddf05f7..ac7114138c3 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -1,15 +1,25 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-import frappe
-from frappe.tests.utils import FrappeTestCase
-from frappe.utils import random_string
-from erpnext.manufacturing.doctype.job_card.job_card import OperationMismatchError, OverlapError
+from typing import Literal
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import random_string
+from frappe.utils.data import add_to_date, now
+
+from erpnext.manufacturing.doctype.job_card.job_card import (
+ JobCardOverTransferError,
+ OperationMismatchError,
+ OverlapError,
+ make_corrective_job_card,
+)
from erpnext.manufacturing.doctype.job_card.job_card import (
make_stock_entry as make_stock_entry_from_jc,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -17,34 +27,36 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestJobCard(FrappeTestCase):
def setUp(self):
make_bom_for_jc_tests()
+ self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order"
+ self.source_warehouse = None
+ self._work_order = None
- transfer_material_against, source_warehouse = None, None
+ @property
+ def work_order(self) -> WorkOrder:
+ """Work Order lazily created for tests."""
+ if not self._work_order:
+ self._work_order = make_wo_order_test_record(
+ item="_Test FG Item 2",
+ qty=2,
+ transfer_material_against=self.transfer_material_against,
+ source_warehouse=self.source_warehouse,
+ )
+ return self._work_order
- tests_that_skip_setup = ("test_job_card_material_transfer_correctness",)
- tests_that_transfer_against_jc = (
- "test_job_card_multiple_materials_transfer",
- "test_job_card_excess_material_transfer",
- "test_job_card_partial_material_transfer",
- )
-
- if self._testMethodName in tests_that_skip_setup:
- return
-
- if self._testMethodName in tests_that_transfer_against_jc:
- transfer_material_against = "Job Card"
- source_warehouse = "Stores - _TC"
-
- self.work_order = make_wo_order_test_record(
- item="_Test FG Item 2",
- qty=2,
- transfer_material_against=transfer_material_against,
- source_warehouse=source_warehouse,
- )
+ def generate_required_stock(self, work_order: WorkOrder) -> None:
+ """Create twice the stock for all required items in work order."""
+ for item in work_order.required_items:
+ make_stock_entry(
+ item_code=item.item_code,
+ target=item.source_warehouse or self.source_warehouse,
+ qty=item.required_qty * 2,
+ basic_rate=100,
+ )
def tearDown(self):
frappe.db.rollback()
- def test_job_card(self):
+ def test_job_card_operations(self):
job_cards = frappe.get_all(
"Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"]
@@ -58,9 +70,6 @@ class TestJobCard(FrappeTestCase):
doc.operation_id = "Test Data"
self.assertRaises(OperationMismatchError, doc.save)
- for d in job_cards:
- frappe.delete_doc("Job Card", d.name)
-
def test_job_card_with_different_work_station(self):
job_cards = frappe.get_all(
"Job Card",
@@ -96,19 +105,11 @@ class TestJobCard(FrappeTestCase):
)
self.assertEqual(completed_qty, job_card.for_quantity)
- doc.cancel()
-
- for d in job_cards:
- frappe.delete_doc("Job Card", d.name)
-
def test_job_card_overlap(self):
wo2 = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
- jc1_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
- jc2_name = frappe.db.get_value("Job Card", {"work_order": wo2.name})
-
- jc1 = frappe.get_doc("Job Card", jc1_name)
- jc2 = frappe.get_doc("Job Card", jc2_name)
+ jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+ jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name})
employee = "_T-Employee-00001" # from test records
@@ -137,10 +138,10 @@ class TestJobCard(FrappeTestCase):
def test_job_card_multiple_materials_transfer(self):
"Test transferring RMs separately against Job Card with multiple RMs."
- make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
- make_stock_entry(
- item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=6, basic_rate=100
- )
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
+
+ self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
job_card = frappe.get_doc("Job Card", job_card_name)
@@ -165,16 +166,58 @@ class TestJobCard(FrappeTestCase):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
+ @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
- make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100)
- make_stock_entry(
- item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
+
+ self.generate_required_stock(self.work_order)
+
+ job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+ self.assertEqual(job_card.status, "Open")
+
+ # fully transfer both RMs
+ transfer_entry_1 = make_stock_entry_from_jc(job_card.name)
+ transfer_entry_1.insert()
+ transfer_entry_1.submit()
+
+ # transfer extra qty of both RM due to previously damaged RM
+ transfer_entry_2 = make_stock_entry_from_jc(job_card.name)
+ # deliberately change 'For Quantity'
+ transfer_entry_2.fg_completed_qty = 1
+ transfer_entry_2.items[0].qty = 5
+ transfer_entry_2.items[1].qty = 3
+ transfer_entry_2.insert()
+ transfer_entry_2.submit()
+
+ job_card.reload()
+ self.assertGreater(job_card.transferred_qty, job_card.for_quantity)
+
+ # Check if 'For Quantity' is negative
+ # as 'transferred_qty' > Qty to Manufacture
+ transfer_entry_3 = make_stock_entry_from_jc(job_card.name)
+ self.assertEqual(transfer_entry_3.fg_completed_qty, 0)
+
+ job_card.append(
+ "time_logs",
+ {"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 2},
)
+ job_card.save()
+ job_card.submit()
+
+ # JC is Completed with excess transfer
+ self.assertEqual(job_card.status, "Completed")
+
+ @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
+ def test_job_card_excess_material_transfer_block(self):
+
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
+
+ self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
- job_card = frappe.get_doc("Job Card", job_card_name)
- self.assertEqual(job_card.status, "Open")
# fully transfer both RMs
transfer_entry_1 = make_stock_entry_from_jc(job_card_name)
@@ -188,39 +231,19 @@ class TestJobCard(FrappeTestCase):
transfer_entry_2.items[0].qty = 5
transfer_entry_2.items[1].qty = 3
transfer_entry_2.insert()
- transfer_entry_2.submit()
-
- job_card.reload()
- self.assertGreater(job_card.transferred_qty, job_card.for_quantity)
-
- # Check if 'For Quantity' is negative
- # as 'transferred_qty' > Qty to Manufacture
- transfer_entry_3 = make_stock_entry_from_jc(job_card_name)
- self.assertEqual(transfer_entry_3.fg_completed_qty, 0)
-
- job_card.append(
- "time_logs",
- {"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 2},
- )
- job_card.save()
- job_card.submit()
-
- # JC is Completed with excess transfer
- self.assertEqual(job_card.status, "Completed")
+ self.assertRaises(JobCardOverTransferError, transfer_entry_2.submit)
def test_job_card_partial_material_transfer(self):
"Test partial material transfer against Job Card"
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
- make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100)
- make_stock_entry(
- item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100
- )
+ self.generate_required_stock(self.work_order)
- job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
- job_card = frappe.get_doc("Job Card", job_card_name)
+ job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
# partially transfer
- transfer_entry = make_stock_entry_from_jc(job_card_name)
+ transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.fg_completed_qty = 1
transfer_entry.get_items()
transfer_entry.insert()
@@ -232,7 +255,7 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(transfer_entry.items[1].qty, 3)
# transfer remaining
- transfer_entry_2 = make_stock_entry_from_jc(job_card_name)
+ transfer_entry_2 = make_stock_entry_from_jc(job_card.name)
self.assertEqual(transfer_entry_2.fg_completed_qty, 1)
self.assertEqual(transfer_entry_2.items[0].qty, 5)
@@ -277,7 +300,73 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(transfer_entry.items[0].item_code, "_Test Item")
self.assertEqual(transfer_entry.items[0].qty, 2)
- # rollback via tearDown method
+ @change_settings(
+ "Manufacturing Settings", {"add_corrective_operation_cost_in_finished_good_valuation": 1}
+ )
+ def test_corrective_costing(self):
+ job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+
+ job_card.append(
+ "time_logs",
+ {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2},
+ )
+ job_card.submit()
+
+ self.work_order.reload()
+ original_cost = self.work_order.total_operating_cost
+
+ # Create a corrective operation against it
+ corrective_action = frappe.get_doc(
+ doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
+ ).insert()
+
+ corrective_job_card = make_corrective_job_card(
+ job_card.name, operation=corrective_action.name, for_operation=job_card.operation
+ )
+ corrective_job_card.hour_rate = 100
+ corrective_job_card.insert()
+ corrective_job_card.append(
+ "time_logs",
+ {
+ "from_time": add_to_date(now(), hours=2),
+ "to_time": add_to_date(now(), hours=2, minutes=30),
+ "completed_qty": 2,
+ },
+ )
+ corrective_job_card.submit()
+
+ self.work_order.reload()
+ cost_after_correction = self.work_order.total_operating_cost
+ self.assertGreater(cost_after_correction, original_cost)
+
+ corrective_job_card.cancel()
+ self.work_order.reload()
+ cost_after_cancel = self.work_order.total_operating_cost
+ self.assertEqual(cost_after_cancel, original_cost)
+
+ def test_job_card_statuses(self):
+ def assertStatus(status):
+ jc.set_status()
+ self.assertEqual(jc.status, status)
+
+ jc = frappe.new_doc("Job Card")
+ jc.for_quantity = 2
+ jc.transferred_qty = 1
+ jc.total_completed_qty = 0
+ assertStatus("Open")
+
+ jc.transferred_qty = jc.for_quantity
+ assertStatus("Material Transferred")
+
+ jc.append("time_logs", {})
+ assertStatus("Work In Progress")
+
+ jc.docstatus = 1
+ jc.total_completed_qty = jc.for_quantity
+ assertStatus("Completed")
+
+ jc.docstatus = 2
+ assertStatus("Cancelled")
def create_bom_with_multiple_operations():
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 891a4978789..e88049d810d 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -798,7 +798,6 @@ def make_bom(**args):
for item in args.raw_materials:
item_doc = frappe.get_doc("Item", item)
-
bom.append(
"items",
{
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 2aba48231be..27e7e24a823 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -417,7 +417,7 @@ class TestWorkOrder(FrappeTestCase):
"doctype": "Item Price",
"item_code": "_Test FG Non Stock Item",
"price_list_rate": 1000,
- "price_list": "Standard Buying",
+ "price_list": "_Test Price List India",
}
).insert(ignore_permissions=True)
@@ -426,8 +426,17 @@ class TestWorkOrder(FrappeTestCase):
item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100
)
- if not frappe.db.get_value("BOM", {"item": fg_item}):
- make_bom(item=fg_item, rate=1000, raw_materials=["_Test FG Item", "_Test FG Non Stock Item"])
+ if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}):
+ bom = make_bom(
+ item=fg_item,
+ rate=1000,
+ raw_materials=["_Test FG Item", "_Test FG Non Stock Item"],
+ do_not_save=True,
+ )
+ bom.rm_cost_as_per = "Price List" # non stock item won't have valuation rate
+ bom.buying_price_list = "_Test Price List India"
+ bom.currency = "INR"
+ bom.save()
wo = make_wo_order_test_record(production_item=fg_item)
diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
index ac2f61c5de6..2aa31be0f0e 100644
--- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
+++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
@@ -21,7 +21,7 @@ def get_exploded_items(bom, data, indent=0, qty=1):
exploded_items = frappe.get_all(
"BOM Item",
filters={"parent": bom},
- fields=["qty", "bom_no", "qty", "scrap", "item_code", "item_name", "description", "uom"],
+ fields=["qty", "bom_no", "qty", "item_code", "item_name", "description", "uom"],
)
for item in exploded_items:
@@ -37,7 +37,6 @@ def get_exploded_items(bom, data, indent=0, qty=1):
"qty": item.qty * qty,
"uom": item.uom,
"description": item.description,
- "scrap": item.scrap,
}
)
if item.bom_no:
@@ -64,5 +63,4 @@ def get_columns():
"fieldname": "description",
"width": 150,
},
- {"label": _("Scrap"), "fieldtype": "data", "fieldname": "scrap", "width": 100},
]
diff --git a/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
index 0a79130f1b2..de96a6c0323 100644
--- a/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
+++ b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
@@ -34,8 +34,7 @@ def get_data(filters):
if filters.get(field):
query_filters[field] = ("in", filters.get(field))
- query_filters["report_date"] = (">=", filters.get("from_date"))
- query_filters["report_date"] = ("<=", filters.get("to_date"))
+ query_filters["report_date"] = ["between", [filters.get("from_date"), filters.get("to_date")]]
return frappe.get_all(
"Quality Inspection", fields=fields, filters=query_filters, order_by="report_date asc"
diff --git a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
index 05ca2a84523..9829a96e09e 100644
--- a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
+++ b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
@@ -402,14 +402,15 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:40:09.474747",
+ "modified": "2022-05-31 22:08:19.408223",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing",
"owner": "Administrator",
"parent_page": "",
"public": 1,
- "restrict_to_domain": "Manufacturing",
+ "quick_lists": [],
+ "restrict_to_domain": "",
"roles": [],
"sequence_id": 17.0,
"shortcuts": [
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 4d9a7e06bfd..5a984635fdc 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -231,7 +231,6 @@ erpnext.patches.v13_0.updates_for_multi_currency_payroll
erpnext.patches.v13_0.update_reason_for_resignation_in_employee
execute:frappe.delete_doc("Report", "Quoted Item Comparison")
erpnext.patches.v13_0.update_member_email_address
-erpnext.patches.v13_0.create_leave_policy_assignment_based_on_employee_current_leave_policy
erpnext.patches.v13_0.update_pos_closing_entry_in_merge_log
erpnext.patches.v13_0.add_po_to_global_search
erpnext.patches.v13_0.update_returned_qty_in_pr_dn
@@ -359,7 +358,7 @@ erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
erpnext.patches.v13_0.update_accounts_in_loan_docs
erpnext.patches.v14_0.update_batch_valuation_flag
erpnext.patches.v14_0.delete_non_profit_doctypes
-erpnext.patches.v14_0.update_employee_advance_status
+erpnext.patches.v13_0.update_employee_advance_status
erpnext.patches.v13_0.add_cost_center_in_loans
erpnext.patches.v13_0.set_return_against_in_pos_invoice_references
erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022
@@ -372,3 +371,6 @@ erpnext.patches.v14_0.discount_accounting_separation
erpnext.patches.v14_0.delete_employee_transfer_property_doctype
erpnext.patches.v13_0.create_accounting_dimensions_in_orders
erpnext.patches.v13_0.set_per_billed_in_return_delivery_note
+execute:frappe.delete_doc("DocType", "Naming Series")
+erpnext.patches.v13_0.set_payroll_entry_status
+erpnext.patches.v13_0.job_card_status_on_hold
diff --git a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py b/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py
deleted file mode 100644
index 59b17eea9fe..00000000000
--- a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# Copyright (c) 2019, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-
-def execute():
- frappe.reload_doc("hr", "doctype", "leave_policy_assignment")
- frappe.reload_doc("hr", "doctype", "employee_grade")
- employee_with_assignment = []
- leave_policy = []
-
- if "leave_policy" in frappe.db.get_table_columns("Employee"):
- employees_with_leave_policy = frappe.db.sql(
- "SELECT name, leave_policy FROM `tabEmployee` WHERE leave_policy IS NOT NULL and leave_policy != ''",
- as_dict=1,
- )
-
- for employee in employees_with_leave_policy:
- alloc = frappe.db.exists(
- "Leave Allocation",
- {"employee": employee.name, "leave_policy": employee.leave_policy, "docstatus": 1},
- )
- if not alloc:
- create_assignment(employee.name, employee.leave_policy)
-
- employee_with_assignment.append(employee.name)
- leave_policy.append(employee.leave_policy)
-
- if "default_leave_policy" in frappe.db.get_table_columns("Employee Grade"):
- employee_grade_with_leave_policy = frappe.db.sql(
- "SELECT name, default_leave_policy FROM `tabEmployee Grade` WHERE default_leave_policy IS NOT NULL and default_leave_policy!=''",
- as_dict=1,
- )
-
- # for whole employee Grade
- for grade in employee_grade_with_leave_policy:
- employees = get_employee_with_grade(grade.name)
- for employee in employees:
-
- if employee not in employee_with_assignment: # Will ensure no duplicate
- alloc = frappe.db.exists(
- "Leave Allocation",
- {"employee": employee.name, "leave_policy": grade.default_leave_policy, "docstatus": 1},
- )
- if not alloc:
- create_assignment(employee.name, grade.default_leave_policy)
- leave_policy.append(grade.default_leave_policy)
-
- # for old Leave allocation and leave policy from allocation, which may got updated in employee grade.
- leave_allocations = frappe.db.sql(
- "SELECT leave_policy, leave_period, employee FROM `tabLeave Allocation` WHERE leave_policy IS NOT NULL and leave_policy != '' and docstatus = 1 ",
- as_dict=1,
- )
-
- for allocation in leave_allocations:
- if allocation.leave_policy not in leave_policy:
- create_assignment(
- allocation.employee,
- allocation.leave_policy,
- leave_period=allocation.leave_period,
- allocation_exists=True,
- )
-
-
-def create_assignment(employee, leave_policy, leave_period=None, allocation_exists=False):
- if frappe.db.get_value("Leave Policy", leave_policy, "docstatus") == 2:
- return
-
- filters = {"employee": employee, "leave_policy": leave_policy}
- if leave_period:
- filters["leave_period"] = leave_period
-
- if not frappe.db.exists("Leave Policy Assignment", filters):
- lpa = frappe.new_doc("Leave Policy Assignment")
- lpa.employee = employee
- lpa.leave_policy = leave_policy
-
- lpa.flags.ignore_mandatory = True
- if allocation_exists:
- lpa.assignment_based_on = "Leave Period"
- lpa.leave_period = leave_period
- lpa.leaves_allocated = 1
-
- lpa.save()
- if allocation_exists:
- lpa.submit()
- # Updating old Leave Allocation
- frappe.db.sql("Update `tabLeave Allocation` set leave_policy_assignment = %s", lpa.name)
-
-
-def get_employee_with_grade(grade):
- return frappe.get_list("Employee", filters={"grade": grade})
diff --git a/erpnext/patches/v13_0/item_naming_series_not_mandatory.py b/erpnext/patches/v13_0/item_naming_series_not_mandatory.py
index 33fb8f963c5..0235a621ce0 100644
--- a/erpnext/patches/v13_0/item_naming_series_not_mandatory.py
+++ b/erpnext/patches/v13_0/item_naming_series_not_mandatory.py
@@ -1,6 +1,6 @@
import frappe
-from erpnext.setup.doctype.naming_series.naming_series import set_by_naming_series
+from erpnext.utilities.naming import set_by_naming_series
def execute():
diff --git a/erpnext/patches/v13_0/job_card_status_on_hold.py b/erpnext/patches/v13_0/job_card_status_on_hold.py
new file mode 100644
index 00000000000..8c67c3c858e
--- /dev/null
+++ b/erpnext/patches/v13_0/job_card_status_on_hold.py
@@ -0,0 +1,19 @@
+import frappe
+
+
+def execute():
+ job_cards = frappe.get_all(
+ "Job Card",
+ {"status": "On Hold", "docstatus": ("!=", 0)},
+ pluck="name",
+ )
+
+ for idx, job_card in enumerate(job_cards):
+ try:
+ doc = frappe.get_doc("Job Card", job_card)
+ doc.set_status()
+ doc.db_set("status", doc.status, update_modified=False)
+ if idx % 100 == 0:
+ frappe.db.commit()
+ except Exception:
+ continue
diff --git a/erpnext/patches/v13_0/set_payroll_entry_status.py b/erpnext/patches/v13_0/set_payroll_entry_status.py
new file mode 100644
index 00000000000..97adff9295f
--- /dev/null
+++ b/erpnext/patches/v13_0/set_payroll_entry_status.py
@@ -0,0 +1,16 @@
+import frappe
+from frappe.query_builder import Case
+
+
+def execute():
+ PayrollEntry = frappe.qb.DocType("Payroll Entry")
+
+ (
+ frappe.qb.update(PayrollEntry).set(
+ "status",
+ Case()
+ .when(PayrollEntry.docstatus == 0, "Draft")
+ .when(PayrollEntry.docstatus == 1, "Submitted")
+ .else_("Cancelled"),
+ )
+ ).run()
diff --git a/erpnext/patches/v14_0/update_employee_advance_status.py b/erpnext/patches/v13_0/update_employee_advance_status.py
similarity index 100%
rename from erpnext/patches/v14_0/update_employee_advance_status.py
rename to erpnext/patches/v13_0/update_employee_advance_status.py
diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
new file mode 100644
index 00000000000..1e0d20d0591
--- /dev/null
+++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
@@ -0,0 +1,135 @@
+import frappe
+from frappe import qb
+from frappe.query_builder import Case
+from frappe.query_builder.custom import ConstantColumn
+from frappe.query_builder.functions import IfNull
+
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_dimensions,
+ make_dimension_in_accounting_doctypes,
+)
+
+
+def create_accounting_dimension_fields():
+ dimensions_and_defaults = get_dimensions()
+ if dimensions_and_defaults:
+ for dimension in dimensions_and_defaults[0]:
+ make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"])
+
+
+def generate_name_for_payment_ledger_entries(gl_entries):
+ for index, entry in enumerate(gl_entries, 1):
+ entry.name = index
+
+
+def get_columns():
+ columns = [
+ "name",
+ "creation",
+ "modified",
+ "modified_by",
+ "owner",
+ "docstatus",
+ "posting_date",
+ "account_type",
+ "account",
+ "party_type",
+ "party",
+ "voucher_type",
+ "voucher_no",
+ "against_voucher_type",
+ "against_voucher_no",
+ "amount",
+ "amount_in_account_currency",
+ "account_currency",
+ "company",
+ "cost_center",
+ "due_date",
+ "finance_book",
+ ]
+
+ dimensions_and_defaults = get_dimensions()
+ if dimensions_and_defaults:
+ for dimension in dimensions_and_defaults[0]:
+ columns.append(dimension.fieldname)
+
+ return columns
+
+
+def build_insert_query():
+ ple = qb.DocType("Payment Ledger Entry")
+ columns = get_columns()
+ insert_query = qb.into(ple)
+
+ # build 'insert' columns in query
+ insert_query = insert_query.columns(tuple(columns))
+
+ return insert_query
+
+
+def insert_chunk_into_payment_ledger(insert_query, gl_entries):
+ if gl_entries:
+ columns = get_columns()
+
+ # build tuple of data with same column order
+ for entry in gl_entries:
+ data = ()
+ for column in columns:
+ data += (entry[column],)
+ insert_query = insert_query.insert(data)
+ insert_query.run()
+
+
+def execute():
+ if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"):
+ # create accounting dimension fields in Payment Ledger
+ create_accounting_dimension_fields()
+
+ gl = qb.DocType("GL Entry")
+ account = qb.DocType("Account")
+
+ gl_entries = (
+ qb.from_(gl)
+ .inner_join(account)
+ .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"])))
+ .select(
+ gl.star,
+ ConstantColumn(1).as_("docstatus"),
+ account.account_type.as_("account_type"),
+ IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"),
+ IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"),
+ # convert debit/credit to amount
+ Case()
+ .when(account.account_type == "Receivable", gl.debit - gl.credit)
+ .else_(gl.credit - gl.debit)
+ .as_("amount"),
+ # convert debit/credit in account currency to amount in account currency
+ Case()
+ .when(
+ account.account_type == "Receivable",
+ gl.debit_in_account_currency - gl.credit_in_account_currency,
+ )
+ .else_(gl.credit_in_account_currency - gl.debit_in_account_currency)
+ .as_("amount_in_account_currency"),
+ )
+ .where(gl.is_cancelled == 0)
+ .orderby(gl.creation)
+ .run(as_dict=True)
+ )
+
+ # primary key(name) for payment ledger records
+ generate_name_for_payment_ledger_entries(gl_entries)
+
+ # split data into chunks
+ chunk_size = 1000
+ try:
+ for i in range(0, len(gl_entries), chunk_size):
+ insert_query = build_insert_query()
+ insert_chunk_into_payment_ledger(insert_query, gl_entries[i : i + chunk_size])
+ frappe.db.commit()
+ except Exception as err:
+ frappe.db.rollback()
+ ple = qb.DocType("Payment Ledger Entry")
+ qb.from_(ple).delete().where(ple.docstatus >= 0).run()
+ frappe.db.commit()
+ raise err
diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py
index 0acd44711b0..8df1bb6e87e 100644
--- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py
+++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py
@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import add_days, cint, cstr, date_diff, getdate, rounded
+from frappe.utils import add_days, cstr, date_diff, flt, getdate, rounded
from erpnext.hr.utils import (
get_holiday_dates_for_employee,
@@ -27,11 +27,14 @@ class EmployeeBenefitApplication(Document):
validate_active_employee(self.employee)
self.validate_duplicate_on_payroll_period()
if not self.max_benefits:
- self.max_benefits = get_max_benefits_remaining(self.employee, self.date, self.payroll_period)
+ self.max_benefits = flt(
+ get_max_benefits_remaining(self.employee, self.date, self.payroll_period),
+ self.precision("max_benefits"),
+ )
if self.max_benefits and self.max_benefits > 0:
self.validate_max_benefit_for_component()
self.validate_prev_benefit_claim()
- if self.remaining_benefit > 0:
+ if self.remaining_benefit and self.remaining_benefit > 0:
self.validate_remaining_benefit_amount()
else:
frappe.throw(
@@ -110,7 +113,7 @@ class EmployeeBenefitApplication(Document):
max_benefit_amount = 0
for employee_benefit in self.employee_benefits:
self.validate_max_benefit(employee_benefit.earning_component)
- max_benefit_amount += employee_benefit.amount
+ max_benefit_amount += flt(employee_benefit.amount)
if max_benefit_amount > self.max_benefits:
frappe.throw(
_("Maximum benefit amount of employee {0} exceeds {1}").format(
@@ -125,7 +128,8 @@ class EmployeeBenefitApplication(Document):
benefit_amount = 0
for employee_benefit in self.employee_benefits:
if employee_benefit.earning_component == earning_component_name:
- benefit_amount += employee_benefit.amount
+ benefit_amount += flt(employee_benefit.amount)
+
prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given(
self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name
)
@@ -207,26 +211,47 @@ def get_max_benefits_remaining(employee, on_date, payroll_period):
def calculate_lwp(employee, start_date, holidays, working_days):
lwp = 0
holidays = "','".join(holidays)
+
for d in range(working_days):
- dt = add_days(cstr(getdate(start_date)), d)
- leave = frappe.db.sql(
- """
- select t1.name, t1.half_day
- from `tabLeave Application` t1, `tabLeave Type` t2
- where t2.name = t1.leave_type
- and t2.is_lwp = 1
- and t1.docstatus = 1
- and t1.employee = %(employee)s
- and CASE WHEN t2.include_holiday != 1 THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date
- WHEN t2.include_holiday THEN %(dt)s between from_date and to_date
- END
- """.format(
- holidays
- ),
- {"employee": employee, "dt": dt},
+ date = add_days(cstr(getdate(start_date)), d)
+
+ LeaveApplication = frappe.qb.DocType("Leave Application")
+ LeaveType = frappe.qb.DocType("Leave Type")
+
+ is_half_day = (
+ frappe.qb.terms.Case()
+ .when(
+ (
+ (LeaveApplication.half_day_date == date)
+ | (LeaveApplication.from_date == LeaveApplication.to_date)
+ ),
+ LeaveApplication.half_day,
+ )
+ .else_(0)
+ ).as_("is_half_day")
+
+ query = (
+ frappe.qb.from_(LeaveApplication)
+ .inner_join(LeaveType)
+ .on((LeaveType.name == LeaveApplication.leave_type))
+ .select(LeaveApplication.name, is_half_day)
+ .where(
+ (LeaveType.is_lwp == 1)
+ & (LeaveApplication.docstatus == 1)
+ & (LeaveApplication.status == "Approved")
+ & (LeaveApplication.employee == employee)
+ & ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date))
+ )
)
- if leave:
- lwp = cint(leave[0][1]) and (lwp + 0.5) or (lwp + 1)
+
+ # if it's a holiday only include if leave type has "include holiday" enabled
+ if date in holidays:
+ query = query.where((LeaveType.include_holiday == "1"))
+ leaves = query.run(as_dict=True)
+
+ if leaves:
+ lwp += 0.5 if leaves[0].is_half_day else 1
+
return lwp
diff --git a/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py
index 02149adfce5..de8f9b6a7ad 100644
--- a/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py
+++ b/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py
@@ -3,6 +3,82 @@
import unittest
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, date_diff, get_year_ending, get_year_start, getdate
-class TestEmployeeBenefitApplication(unittest.TestCase):
- pass
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
+from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
+from erpnext.hr.utils import get_holiday_dates_for_employee
+from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import (
+ calculate_lwp,
+)
+from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
+ create_payroll_period,
+)
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
+from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
+from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
+
+
+class TestEmployeeBenefitApplication(FrappeTestCase):
+ def setUp(self):
+ date = getdate()
+ make_holiday_list(from_date=get_year_start(date), to_date=get_year_ending(date))
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_employee_benefit_application(self):
+ payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company")
+ employee = make_employee("test_employee_benefits@salary.com", company="_Test Company")
+ first_sunday = get_first_sunday("Salary Slip Test Holiday List")
+
+ leave_application = make_leave_application(
+ employee,
+ add_days(first_sunday, 1),
+ add_days(first_sunday, 3),
+ "Leave Without Pay",
+ half_day=1,
+ half_day_date=add_days(first_sunday, 1),
+ submit=True,
+ )
+
+ frappe.db.set_value("Leave Type", "Leave Without Pay", "include_holiday", 0)
+ salary_structure = make_salary_structure(
+ "Test Employee Benefits",
+ "Monthly",
+ other_details={"max_benefits": 100000},
+ include_flexi_benefits=True,
+ employee=employee,
+ payroll_period=payroll_period,
+ )
+ salary_slip = make_salary_slip(salary_structure.name, employee=employee, posting_date=getdate())
+ salary_slip.insert()
+ salary_slip.submit()
+
+ application = make_employee_benefit_application(
+ employee, payroll_period.name, date=leave_application.to_date
+ )
+ self.assertEqual(application.employee_benefits[0].max_benefit_amount, 15000)
+
+ holidays = get_holiday_dates_for_employee(employee, payroll_period.start_date, application.date)
+ working_days = date_diff(application.date, payroll_period.start_date) + 1
+ lwp = calculate_lwp(employee, payroll_period.start_date, holidays, working_days)
+ self.assertEqual(lwp, 2.5)
+
+
+def make_employee_benefit_application(employee, payroll_period, date):
+ frappe.db.delete("Employee Benefit Application")
+
+ return frappe.get_doc(
+ {
+ "doctype": "Employee Benefit Application",
+ "employee": employee,
+ "date": date,
+ "payroll_period": payroll_period,
+ "employee_benefits": [{"earning_component": "Medical Allowance", "amount": 1500}],
+ }
+ ).insert()
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py b/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py
index c0ef2eee78c..3d1d96598fc 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py
@@ -33,7 +33,9 @@ class EmployeeTaxExemptionDeclaration(Document):
self.total_declared_amount += flt(d.amount)
def set_total_exemption_amount(self):
- self.total_exemption_amount = get_total_exemption_amount(self.declarations)
+ self.total_exemption_amount = flt(
+ get_total_exemption_amount(self.declarations), self.precision("total_exemption_amount")
+ )
def calculate_hra_exemption(self):
self.salary_structure_hra, self.annual_hra_exemption, self.monthly_hra_exemption = 0, 0, 0
@@ -41,9 +43,18 @@ class EmployeeTaxExemptionDeclaration(Document):
hra_exemption = calculate_annual_eligible_hra_exemption(self)
if hra_exemption:
self.total_exemption_amount += hra_exemption["annual_exemption"]
- self.salary_structure_hra = hra_exemption["hra_amount"]
- self.annual_hra_exemption = hra_exemption["annual_exemption"]
- self.monthly_hra_exemption = hra_exemption["monthly_exemption"]
+ self.total_exemption_amount = flt(
+ self.total_exemption_amount, self.precision("total_exemption_amount")
+ )
+ self.salary_structure_hra = flt(
+ hra_exemption["hra_amount"], self.precision("salary_structure_hra")
+ )
+ self.annual_hra_exemption = flt(
+ hra_exemption["annual_exemption"], self.precision("annual_hra_exemption")
+ )
+ self.monthly_hra_exemption = flt(
+ hra_exemption["monthly_exemption"], self.precision("monthly_hra_exemption")
+ )
@frappe.whitelist()
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py b/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py
index 1d90e7383fe..2d8df350118 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py
@@ -4,25 +4,28 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_months, getdate
import erpnext
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.utils import DuplicateDeclarationError
-class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
+class TestEmployeeTaxExemptionDeclaration(FrappeTestCase):
def setUp(self):
- make_employee("employee@taxexepmtion.com")
- make_employee("employee1@taxexepmtion.com")
- create_payroll_period()
+ make_employee("employee@taxexemption.com", company="_Test Company")
+ make_employee("employee1@taxexemption.com", company="_Test Company")
+ create_payroll_period(company="_Test Company")
create_exemption_category()
- frappe.db.sql("""delete from `tabEmployee Tax Exemption Declaration`""")
+ frappe.db.delete("Employee Tax Exemption Declaration")
+ frappe.db.delete("Salary Structure Assignment")
def test_duplicate_category_in_declaration(self):
declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -46,7 +49,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -68,7 +71,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
duplicate_declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -83,7 +86,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
)
self.assertRaises(DuplicateDeclarationError, duplicate_declaration.insert)
duplicate_declaration.employee = frappe.get_value(
- "Employee", {"user_id": "employee1@taxexepmtion.com"}, "name"
+ "Employee", {"user_id": "employee1@taxexemption.com"}, "name"
)
self.assertTrue(duplicate_declaration.insert)
@@ -91,7 +94,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -112,6 +115,298 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
self.assertEqual(declaration.total_exemption_amount, 100000)
+ def test_india_hra_exemption(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Monthly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 50000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test Sub Category",
+ exemption_category="_Test Category",
+ amount=80000,
+ ),
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Monthly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 3000)
+ self.assertEqual(declaration.annual_hra_exemption, 36000)
+ # 100000 Standard Exemption + 36000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 136000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_daily_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Daily")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 170000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Daily HRA received = 3000
+ # should set HRA exemption as per (rent - 10% of Basic Salary), that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 17916.67)
+ self.assertEqual(declaration.annual_hra_exemption, 215000)
+ # 50000 Standard Exemption + 215000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 265000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_weekly_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Weekly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 170000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Weekly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 13000)
+ self.assertEqual(declaration.annual_hra_exemption, 156000)
+ # 50000 Standard Exemption + 156000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 206000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_fortnightly_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Fortnightly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 170000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Fortnightly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 6500)
+ self.assertEqual(declaration.annual_hra_exemption, 78000)
+ # 50000 Standard Exemption + 78000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 128000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_bimonthly_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Bimonthly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 50000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test Sub Category",
+ exemption_category="_Test Category",
+ amount=80000,
+ ),
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Bimonthly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 1500)
+ self.assertEqual(declaration.annual_hra_exemption, 18000)
+ # 100000 Standard Exemption + 18000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 118000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_multiple_salary_structure_assignments(self):
+ from erpnext.payroll.doctype.salary_slip.test_salary_slip import create_tax_slab
+ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
+ create_salary_structure_assignment,
+ make_salary_structure,
+ )
+
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ employee = make_employee("employee@taxexemption2.com", company="_Test Company")
+ payroll_period = create_payroll_period(name="_Test Payroll Period", company="_Test Company")
+
+ create_tax_slab(
+ payroll_period,
+ allow_tax_exemption=True,
+ currency="INR",
+ effective_date=getdate("2019-04-01"),
+ company="_Test Company",
+ )
+
+ frappe.db.set_value(
+ "Company", "_Test Company", {"basic_component": "Basic Salary", "hra_component": "HRA"}
+ )
+
+ # salary structure with base 50000, HRA 3000
+ make_salary_structure(
+ "Monthly Structure for HRA Exemption 1",
+ "Monthly",
+ employee=employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period.name,
+ from_date=payroll_period.start_date,
+ )
+
+ # salary structure with base 70000, HRA = base * 0.2 = 14000
+ salary_structure = make_salary_structure(
+ "Monthly Structure for HRA Exemption 2",
+ "Monthly",
+ employee=employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period.name,
+ from_date=payroll_period.start_date,
+ dont_submit=True,
+ )
+ for component_row in salary_structure.earnings:
+ if component_row.salary_component == "HRA":
+ component_row.amount = 0
+ component_row.amount_based_on_formula = 1
+ component_row.formula = "base * 0.2"
+ break
+
+ salary_structure.submit()
+
+ create_salary_structure_assignment(
+ employee,
+ salary_structure.name,
+ from_date=add_months(payroll_period.start_date, 6),
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period.name,
+ base=70000,
+ allow_duplicate=True,
+ )
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": payroll_period.name,
+ "currency": "INR",
+ "monthly_house_rent": 50000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Monthly HRA received = 50000 * 6 months + 70000 * 6 months
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 8500)
+ self.assertEqual(declaration.annual_hra_exemption, 102000)
+ # 50000 Standard Exemption + 102000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 152000)
+
+ # reset
+ frappe.flags.country = current_country
+
def create_payroll_period(**args):
args = frappe._dict(args)
@@ -163,3 +458,33 @@ def create_exemption_category():
"is_active": 1,
}
).insert()
+
+
+def setup_hra_exemption_prerequisites(frequency, employee=None):
+ from erpnext.payroll.doctype.salary_slip.test_salary_slip import create_tax_slab
+ from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
+
+ payroll_period = create_payroll_period(name="_Test Payroll Period", company="_Test Company")
+ if not employee:
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ create_tax_slab(
+ payroll_period,
+ allow_tax_exemption=True,
+ currency="INR",
+ effective_date=getdate("2019-04-01"),
+ company="_Test Company",
+ )
+
+ make_salary_structure(
+ f"{frequency} Structure for HRA Exemption",
+ frequency,
+ employee=employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period,
+ )
+
+ frappe.db.set_value(
+ "Company", "_Test Company", {"basic_component": "Basic Salary", "hra_component": "HRA"}
+ )
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py
index c52efaba592..b3b66b9e7b1 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py
@@ -31,7 +31,9 @@ class EmployeeTaxExemptionProofSubmission(Document):
self.total_actual_amount += flt(d.amount)
def set_total_exemption_amount(self):
- self.exemption_amount = get_total_exemption_amount(self.tax_exemption_proofs)
+ self.exemption_amount = flt(
+ get_total_exemption_amount(self.tax_exemption_proofs), self.precision("exemption_amount")
+ )
def calculate_hra_exemption(self):
self.monthly_hra_exemption, self.monthly_house_rent, self.total_eligible_hra_exemption = 0, 0, 0
@@ -39,6 +41,13 @@ class EmployeeTaxExemptionProofSubmission(Document):
hra_exemption = calculate_hra_exemption_for_period(self)
if hra_exemption:
self.exemption_amount += hra_exemption["total_eligible_hra_exemption"]
- self.monthly_hra_exemption = hra_exemption["monthly_exemption"]
- self.monthly_house_rent = hra_exemption["monthly_house_rent"]
- self.total_eligible_hra_exemption = hra_exemption["total_eligible_hra_exemption"]
+ self.exemption_amount = flt(self.exemption_amount, self.precision("exemption_amount"))
+ self.monthly_hra_exemption = flt(
+ hra_exemption["monthly_exemption"], self.precision("monthly_hra_exemption")
+ )
+ self.monthly_house_rent = flt(
+ hra_exemption["monthly_house_rent"], self.precision("monthly_house_rent")
+ )
+ self.total_eligible_hra_exemption = flt(
+ hra_exemption["total_eligible_hra_exemption"], self.precision("total_eligible_hra_exemption")
+ )
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py
index 58b2c1af058..416cf316c97 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py
@@ -4,22 +4,26 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
+from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
create_exemption_category,
create_payroll_period,
+ setup_hra_exemption_prerequisites,
)
-class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
- def setup(self):
- make_employee("employee@proofsubmission.com")
- create_payroll_period()
+class TestEmployeeTaxExemptionProofSubmission(FrappeTestCase):
+ def setUp(self):
+ make_employee("employee@proofsubmission.com", company="_Test Company")
+ create_payroll_period(company="_Test Company")
create_exemption_category()
- frappe.db.sql("""delete from `tabEmployee Tax Exemption Proof Submission`""")
+ frappe.db.delete("Employee Tax Exemption Proof Submission")
+ frappe.db.delete("Salary Structure Assignment")
def test_exemption_amount_lesser_than_category_max(self):
- declaration = frappe.get_doc(
+ proof = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Proof Submission",
"employee": frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name"),
@@ -34,8 +38,8 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
],
}
)
- self.assertRaises(frappe.ValidationError, declaration.save)
- declaration = frappe.get_doc(
+ self.assertRaises(frappe.ValidationError, proof.save)
+ proof = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Proof Submission",
"payroll_period": "Test Payroll Period",
@@ -50,11 +54,11 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
],
}
)
- self.assertTrue(declaration.save)
- self.assertTrue(declaration.submit)
+ self.assertTrue(proof.save)
+ self.assertTrue(proof.submit)
def test_duplicate_category_in_proof_submission(self):
- declaration = frappe.get_doc(
+ proof = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Proof Submission",
"employee": frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name"),
@@ -74,4 +78,59 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
],
}
)
- self.assertRaises(frappe.ValidationError, declaration.save)
+ self.assertRaises(frappe.ValidationError, proof.save)
+
+ def test_india_hra_exemption(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ employee = frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name")
+ setup_hra_exemption_prerequisites("Monthly", employee)
+ payroll_period = frappe.db.get_value(
+ "Payroll Period", "_Test Payroll Period", ["start_date", "end_date"], as_dict=True
+ )
+
+ proof = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Proof Submission",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "house_rent_payment_amount": 600000,
+ "rented_in_metro_city": 1,
+ "rented_from_date": payroll_period.start_date,
+ "rented_to_date": payroll_period.end_date,
+ "tax_exemption_proofs": [
+ dict(
+ exemption_sub_category="_Test Sub Category",
+ exemption_category="_Test Category",
+ type_of_proof="Test Proof",
+ amount=100000,
+ ),
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ type_of_proof="Test Proof",
+ amount=50000,
+ ),
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(proof.monthly_house_rent, 50000)
+
+ # Monthly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(proof.monthly_hra_exemption, 3000)
+ self.assertEqual(proof.total_eligible_hra_exemption, 36000)
+
+ # total exemptions + house rent payment amount
+ self.assertEqual(proof.total_actual_amount, 750000)
+
+ # 100000 Standard Exemption + 36000 HRA exemption
+ self.assertEqual(proof.exemption_amount, 136000)
+
+ # reset
+ frappe.flags.country = current_country
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json
index 1fd1cecaaaa..c540baf7e63 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.json
+++ b/erpnext/payroll/doctype/gratuity/gratuity.json
@@ -76,9 +76,8 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
- "options": "Draft\nUnpaid\nPaid",
- "read_only": 1,
- "reqd": 1
+ "options": "Draft\nUnpaid\nPaid\nSubmitted\nCancelled",
+ "read_only": 1
},
{
"depends_on": "eval: !doc.pay_via_salary_slip",
@@ -194,7 +193,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-02-02 14:00:45.536152",
+ "modified": "2022-05-27 13:56:14.349183",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Gratuity",
diff --git a/erpnext/payroll/doctype/gratuity/gratuity_list.js b/erpnext/payroll/doctype/gratuity/gratuity_list.js
new file mode 100644
index 00000000000..20e3d5b4e52
--- /dev/null
+++ b/erpnext/payroll/doctype/gratuity/gratuity_list.js
@@ -0,0 +1,12 @@
+frappe.listview_settings["Gratuity"] = {
+ get_indicator: function(doc) {
+ let status_color = {
+ "Draft": "red",
+ "Submitted": "blue",
+ "Cancelled": "red",
+ "Paid": "green",
+ "Unpaid": "orange",
+ };
+ return [__(doc.status), status_color[doc.status], "status,=,"+doc.status];
+ }
+};
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py
index aa03d80d635..1155a06eddd 100644
--- a/erpnext/payroll/doctype/gratuity/test_gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py
@@ -4,57 +4,69 @@
import unittest
import frappe
-from frappe.utils import add_days, flt, get_datetime, getdate
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, add_months, floor, flt, get_datetime, get_first_day, getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.expense_claim.test_expense_claim import get_payable_account
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.payroll.doctype.gratuity.gratuity import get_last_salary_slip
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_deduction_salary_component,
make_earning_salary_component,
make_employee_salary_slip,
+ make_holiday_list,
)
+from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule
test_dependencies = ["Salary Component", "Salary Slip", "Account"]
-class TestGratuity(unittest.TestCase):
+class TestGratuity(FrappeTestCase):
def setUp(self):
frappe.db.delete("Gratuity")
+ frappe.db.delete("Salary Slip")
frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
make_earning_salary_component(
setup=True, test_tax=True, company_list=["_Test Company"], include_flexi_benefits=True
)
make_deduction_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
+ make_holiday_list()
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_get_last_salary_slip_should_return_none_for_new_employee(self):
new_employee = make_employee("new_employee@salary.com", company="_Test Company")
salary_slip = get_last_salary_slip(new_employee)
- assert salary_slip is None
+ self.assertIsNone(salary_slip)
- def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self):
- employee, sal_slip = create_employee_and_get_last_salary_slip()
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_gratuity_based_on_current_slab_via_additional_salary(self):
+ """
+ Range | Fraction
+ 5-0 | 1
+ """
+ doj = add_days(getdate(), -(6 * 365))
+ relieving_date = getdate()
+
+ employee = make_employee(
+ "test_employee_gratuity@salary.com",
+ company="_Test Company",
+ date_of_joining=doj,
+ relieving_date=relieving_date,
+ )
+ sal_slip = create_salary_slip("test_employee_gratuity@salary.com")
rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)")
gratuity = create_gratuity(pay_via_salary_slip=1, employee=employee, rule=rule.name)
# work experience calculation
- date_of_joining, relieving_date = frappe.db.get_value(
- "Employee", employee, ["date_of_joining", "relieving_date"]
- )
- employee_total_workings_days = (
- get_datetime(relieving_date) - get_datetime(date_of_joining)
- ).days
+ employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(doj)).days
+ experience = floor(employee_total_workings_days / rule.total_working_days_per_year)
+ self.assertEqual(gratuity.current_work_experience, experience)
- experience = employee_total_workings_days / rule.total_working_days_per_year
- gratuity.reload()
- from math import floor
-
- self.assertEqual(floor(experience), gratuity.current_work_experience)
-
- # amount Calculation
+ # amount calculation
component_amount = frappe.get_all(
"Salary Detail",
filters={
@@ -64,20 +76,44 @@ class TestGratuity(unittest.TestCase):
"salary_component": "Basic Salary",
},
fields=["amount"],
+ limit=1,
)
-
- """ 5 - 0 fraction is 1 """
-
gratuity_amount = component_amount[0].amount * experience
- gratuity.reload()
-
self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2))
# additional salary creation (Pay via salary slip)
self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name}))
- def test_check_gratuity_amount_based_on_all_previous_slabs(self):
- employee, sal_slip = create_employee_and_get_last_salary_slip()
+ # gratuity should be marked "Paid" on the next salary slip submission
+ salary_slip = make_salary_slip("Test Gratuity", employee=employee)
+ salary_slip.posting_date = getdate()
+ salary_slip.insert()
+ salary_slip.submit()
+
+ gratuity.reload()
+ self.assertEqual(gratuity.status, "Paid")
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_gratuity_based_on_all_previous_slabs_via_payment_entry(self):
+ """
+ Range | Fraction
+ 0-1 | 0
+ 1-5 | 0.7
+ 5-0 | 1
+ """
+ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+
+ doj = add_days(getdate(), -(6 * 365))
+ relieving_date = getdate()
+
+ employee = make_employee(
+ "test_employee_gratuity@salary.com",
+ company="_Test Company",
+ date_of_joining=doj,
+ relieving_date=relieving_date,
+ )
+
+ sal_slip = create_salary_slip("test_employee_gratuity@salary.com")
rule = get_gratuity_rule("Rule Under Limited Contract (UAE)")
set_mode_of_payment_account()
@@ -86,22 +122,11 @@ class TestGratuity(unittest.TestCase):
)
# work experience calculation
- date_of_joining, relieving_date = frappe.db.get_value(
- "Employee", employee, ["date_of_joining", "relieving_date"]
- )
- employee_total_workings_days = (
- get_datetime(relieving_date) - get_datetime(date_of_joining)
- ).days
+ employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(doj)).days
+ experience = floor(employee_total_workings_days / rule.total_working_days_per_year)
+ self.assertEqual(gratuity.current_work_experience, experience)
- experience = employee_total_workings_days / rule.total_working_days_per_year
-
- gratuity.reload()
-
- from math import floor
-
- self.assertEqual(floor(experience), gratuity.current_work_experience)
-
- # amount Calculation
+ # amount calculation
component_amount = frappe.get_all(
"Salary Detail",
filters={
@@ -111,35 +136,22 @@ class TestGratuity(unittest.TestCase):
"salary_component": "Basic Salary",
},
fields=["amount"],
+ limit=1,
)
- """ range | Fraction
- 0-1 | 0
- 1-5 | 0.7
- 5-0 | 1
- """
-
gratuity_amount = ((0 * 1) + (4 * 0.7) + (1 * 1)) * component_amount[0].amount
- gratuity.reload()
-
self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2))
self.assertEqual(gratuity.status, "Unpaid")
- from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+ pe = get_payment_entry("Gratuity", gratuity.name)
+ pe.reference_no = "123467"
+ pe.reference_date = getdate()
+ pe.submit()
- pay_entry = get_payment_entry("Gratuity", gratuity.name)
- pay_entry.reference_no = "123467"
- pay_entry.reference_date = getdate()
- pay_entry.save()
- pay_entry.submit()
gratuity.reload()
-
self.assertEqual(gratuity.status, "Paid")
self.assertEqual(flt(gratuity.paid_amount, 2), flt(gratuity.amount, 2))
- def tearDown(self):
- frappe.db.rollback()
-
def get_gratuity_rule(name):
rule = frappe.db.exists("Gratuity Rule", name)
@@ -149,7 +161,6 @@ def get_gratuity_rule(name):
rule.applicable_earnings_component = []
rule.append("applicable_earnings_component", {"salary_component": "Basic Salary"})
rule.save()
- rule.reload()
return rule
@@ -204,23 +215,17 @@ def create_account():
).insert(ignore_permissions=True)
-def create_employee_and_get_last_salary_slip():
- employee = make_employee("test_employee@salary.com", company="_Test Company")
- frappe.db.set_value("Employee", employee, "relieving_date", getdate())
- frappe.db.set_value("Employee", employee, "date_of_joining", add_days(getdate(), -(6 * 365)))
+def create_salary_slip(employee):
if not frappe.db.exists("Salary Slip", {"employee": employee}):
- salary_slip = make_employee_salary_slip("test_employee@salary.com", "Monthly")
+ posting_date = get_first_day(add_months(getdate(), -1))
+ salary_slip = make_employee_salary_slip(
+ employee, "Monthly", "Test Gratuity", posting_date=posting_date
+ )
+ salary_slip.start_date = posting_date
+ salary_slip.end_date = None
salary_slip.submit()
salary_slip = salary_slip.name
else:
salary_slip = get_last_salary_slip(employee)
- if not frappe.db.get_value("Employee", "test_employee@salary.com", "holiday_list"):
- from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
-
- make_holiday_list()
- frappe.db.set_value(
- "Company", "_Test Company", "default_holiday_list", "Salary Slip Test Holiday List"
- )
-
- return employee, salary_slip
+ return salary_slip
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js
index 62e183e59c7..b06f3502e26 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js
@@ -40,30 +40,69 @@ frappe.ui.form.on('Payroll Entry', {
},
refresh: function (frm) {
- if (frm.doc.docstatus == 0) {
- if (!frm.is_new()) {
+ if (frm.doc.docstatus === 0 && !frm.is_new()) {
+ frm.page.clear_primary_action();
+ frm.add_custom_button(__("Get Employees"),
+ function () {
+ frm.events.get_employee_details(frm);
+ }
+ ).toggleClass("btn-primary", !(frm.doc.employees || []).length);
+ }
+
+ if (
+ (frm.doc.employees || []).length
+ && !frappe.model.has_workflow(frm.doctype)
+ && !cint(frm.doc.salary_slips_created)
+ && (frm.doc.docstatus != 2)
+ ) {
+ if (frm.doc.docstatus == 0) {
frm.page.clear_primary_action();
- frm.add_custom_button(__("Get Employees"),
- function () {
- frm.events.get_employee_details(frm);
- }
- ).toggleClass('btn-primary', !(frm.doc.employees || []).length);
- }
- if ((frm.doc.employees || []).length && !frappe.model.has_workflow(frm.doctype)) {
- frm.page.clear_primary_action();
- frm.page.set_primary_action(__('Create Salary Slips'), () => {
- frm.save('Submit').then(() => {
+ frm.page.set_primary_action(__("Create Salary Slips"), () => {
+ frm.save("Submit").then(() => {
frm.page.clear_primary_action();
frm.refresh();
frm.events.refresh(frm);
});
});
+ } else if (frm.doc.docstatus == 1 && frm.doc.status == "Failed") {
+ frm.add_custom_button(__("Create Salary Slip"), function () {
+ frm.call("create_salary_slips", {}, () => {
+ frm.reload_doc();
+ });
+ }).addClass("btn-primary");
}
}
- if (frm.doc.docstatus == 1) {
+
+ if (frm.doc.docstatus == 1 && frm.doc.status == "Submitted") {
if (frm.custom_buttons) frm.clear_custom_buttons();
frm.events.add_context_buttons(frm);
}
+
+ if (frm.doc.status == "Failed" && frm.doc.error_message) {
+ const issue = `issue`;
+ let process = (cint(frm.doc.salary_slips_created)) ? "submission" : "creation";
+
+ frm.dashboard.set_headline(
+ __("Salary Slip {0} failed. You can resolve the {1} and retry {0}.", [process, issue])
+ );
+
+ $("#jump_to_error").on("click", (e) => {
+ e.preventDefault();
+ frappe.utils.scroll_to(
+ frm.get_field("error_message").$wrapper,
+ true,
+ 30
+ );
+ });
+ }
+
+ frappe.realtime.on("completed_salary_slip_creation", function() {
+ frm.reload_doc();
+ });
+
+ frappe.realtime.on("completed_salary_slip_submission", function() {
+ frm.reload_doc();
+ });
},
get_employee_details: function (frm) {
@@ -88,7 +127,7 @@ frappe.ui.form.on('Payroll Entry', {
doc: frm.doc,
method: "create_salary_slips",
callback: function () {
- frm.refresh();
+ frm.reload_doc();
frm.toolbar.refresh();
}
});
@@ -97,7 +136,7 @@ frappe.ui.form.on('Payroll Entry', {
add_context_buttons: function (frm) {
if (frm.doc.salary_slips_submitted || (frm.doc.__onload && frm.doc.__onload.submitted_ss)) {
frm.events.add_bank_entry_button(frm);
- } else if (frm.doc.salary_slips_created) {
+ } else if (frm.doc.salary_slips_created && frm.doc.status != 'Queued') {
frm.add_custom_button(__("Submit Salary Slip"), function () {
submit_salary_slip(frm);
}).addClass("btn-primary");
@@ -331,6 +370,7 @@ const submit_salary_slip = function (frm) {
method: 'submit_salary_slips',
args: {},
callback: function () {
+ frm.reload_doc();
frm.events.refresh(frm);
},
doc: frm.doc,
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.json b/erpnext/payroll/doctype/payroll_entry/payroll_entry.json
index 0444134aa4d..17882eb5d94 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.json
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.json
@@ -8,11 +8,11 @@
"engine": "InnoDB",
"field_order": [
"section_break0",
- "column_break0",
"posting_date",
"payroll_frequency",
"company",
"column_break1",
+ "status",
"currency",
"exchange_rate",
"payroll_payable_account",
@@ -41,11 +41,14 @@
"cost_center",
"account",
"payment_account",
- "amended_from",
"column_break_33",
"bank_account",
"salary_slips_created",
- "salary_slips_submitted"
+ "salary_slips_submitted",
+ "failure_details_section",
+ "error_message",
+ "section_break_41",
+ "amended_from"
],
"fields": [
{
@@ -53,11 +56,6 @@
"fieldtype": "Section Break",
"label": "Select Employees"
},
- {
- "fieldname": "column_break0",
- "fieldtype": "Column Break",
- "width": "50%"
- },
{
"default": "Today",
"fieldname": "posting_date",
@@ -231,6 +229,7 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Salary Slips Created",
+ "no_copy": 1,
"read_only": 1
},
{
@@ -239,6 +238,7 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Salary Slips Submitted",
+ "no_copy": 1,
"read_only": 1
},
{
@@ -284,15 +284,44 @@
"label": "Payroll Payable Account",
"options": "Account",
"reqd": 1
+ },
+ {
+ "collapsible": 1,
+ "collapsible_depends_on": "error_message",
+ "depends_on": "eval:doc.status=='Failed';",
+ "fieldname": "failure_details_section",
+ "fieldtype": "Section Break",
+ "label": "Failure Details"
+ },
+ {
+ "depends_on": "eval:doc.status=='Failed';",
+ "fieldname": "error_message",
+ "fieldtype": "Small Text",
+ "label": "Error Message",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_41",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "label": "Status",
+ "options": "Draft\nSubmitted\nCancelled\nQueued\nFailed",
+ "print_hide": 1,
+ "read_only": 1
}
],
"icon": "fa fa-cog",
"is_submittable": 1,
"links": [],
- "modified": "2020-12-17 15:13:17.766210",
+ "modified": "2022-03-16 12:45:21.662765",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Payroll Entry",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -308,5 +337,6 @@
}
],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
index 54d56f9612f..620fcadceb2 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
@@ -1,6 +1,7 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
+import json
import frappe
from dateutil.relativedelta import relativedelta
@@ -16,6 +17,7 @@ from frappe.utils import (
comma_and,
date_diff,
flt,
+ get_link_to_form,
getdate,
)
@@ -39,16 +41,28 @@ class PayrollEntry(Document):
def validate(self):
self.number_of_employees = len(self.employees)
+ self.set_status()
def on_submit(self):
+ self.set_status(update=True, status="Submitted")
self.create_salary_slips()
def before_submit(self):
self.validate_employee_details()
+ self.validate_payroll_payable_account()
if self.validate_attendance:
if self.validate_employee_attendance():
frappe.throw(_("Cannot Submit, Employees left to mark attendance"))
+ def set_status(self, status=None, update=False):
+ if not status:
+ status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
+
+ if update:
+ self.db_set("status", status)
+ else:
+ self.status = status
+
def validate_employee_details(self):
emp_with_sal_slip = []
for employee_details in self.employees:
@@ -66,6 +80,14 @@ class PayrollEntry(Document):
if len(emp_with_sal_slip):
frappe.throw(_("Salary Slip already exists for {0}").format(comma_and(emp_with_sal_slip)))
+ def validate_payroll_payable_account(self):
+ if frappe.db.get_value("Account", self.payroll_payable_account, "account_type"):
+ frappe.throw(
+ _(
+ "Account type cannot be set for payroll payable account {0}, please remove and try again"
+ ).format(frappe.bold(get_link_to_form("Account", self.payroll_payable_account)))
+ )
+
def on_cancel(self):
frappe.delete_doc(
"Salary Slip",
@@ -77,6 +99,8 @@ class PayrollEntry(Document):
)
self.db_set("salary_slips_created", 0)
self.db_set("salary_slips_submitted", 0)
+ self.set_status(update=True, status="Cancelled")
+ self.db_set("error_message", "")
def get_emp_list(self):
"""
@@ -173,8 +197,20 @@ class PayrollEntry(Document):
"currency": self.currency,
}
)
- if len(employees) > 30:
- frappe.enqueue(create_salary_slips_for_employees, timeout=600, employees=employees, args=args)
+ if len(employees) > 30 or frappe.flags.enqueue_payroll_entry:
+ self.db_set("status", "Queued")
+ frappe.enqueue(
+ create_salary_slips_for_employees,
+ timeout=600,
+ employees=employees,
+ args=args,
+ publish_progress=False,
+ )
+ frappe.msgprint(
+ _("Salary Slip creation is queued. It may take a few minutes"),
+ alert=True,
+ indicator="blue",
+ )
else:
create_salary_slips_for_employees(employees, args, publish_progress=False)
# since this method is called via frm.call this doc needs to be updated manually
@@ -204,13 +240,23 @@ class PayrollEntry(Document):
@frappe.whitelist()
def submit_salary_slips(self):
self.check_permission("write")
- ss_list = self.get_sal_slip_list(ss_status=0)
- if len(ss_list) > 30:
+ salary_slips = self.get_sal_slip_list(ss_status=0)
+ if len(salary_slips) > 30 or frappe.flags.enqueue_payroll_entry:
+ self.db_set("status", "Queued")
frappe.enqueue(
- submit_salary_slips_for_employees, timeout=600, payroll_entry=self, salary_slips=ss_list
+ submit_salary_slips_for_employees,
+ timeout=600,
+ payroll_entry=self,
+ salary_slips=salary_slips,
+ publish_progress=False,
+ )
+ frappe.msgprint(
+ _("Salary Slip submission is queued. It may take a few minutes"),
+ alert=True,
+ indicator="blue",
)
else:
- submit_salary_slips_for_employees(self, ss_list, publish_progress=False)
+ submit_salary_slips_for_employees(self, salary_slips, publish_progress=False)
def email_salary_slip(self, submitted_ss):
if frappe.db.get_single_value("Payroll Settings", "email_salary_slip_to_employee"):
@@ -223,7 +269,11 @@ class PayrollEntry(Document):
)
if not account:
- frappe.throw(_("Please set account in Salary Component {0}").format(salary_component))
+ frappe.throw(
+ _("Please set account in Salary Component {0}").format(
+ get_link_to_form("Salary Component", salary_component)
+ )
+ )
return account
@@ -780,36 +830,80 @@ def payroll_entry_has_bank_entries(name):
return response
+def log_payroll_failure(process, payroll_entry, error):
+ error_log = frappe.log_error(
+ title=_("Salary Slip {0} failed for Payroll Entry {1}").format(process, payroll_entry.name)
+ )
+ message_log = frappe.message_log.pop() if frappe.message_log else str(error)
+
+ try:
+ error_message = json.loads(message_log).get("message")
+ except Exception:
+ error_message = message_log
+
+ error_message += "\n" + _("Check Error Log {0} for more details.").format(
+ get_link_to_form("Error Log", error_log.name)
+ )
+
+ payroll_entry.db_set({"error_message": error_message, "status": "Failed"})
+
+
def create_salary_slips_for_employees(employees, args, publish_progress=True):
- salary_slips_exists_for = get_existing_salary_slips(employees, args)
- count = 0
- salary_slips_not_created = []
- for emp in employees:
- if emp not in salary_slips_exists_for:
- args.update({"doctype": "Salary Slip", "employee": emp})
- ss = frappe.get_doc(args)
- ss.insert()
- count += 1
- if publish_progress:
- frappe.publish_progress(
- count * 100 / len(set(employees) - set(salary_slips_exists_for)),
- title=_("Creating Salary Slips..."),
- )
+ try:
+ payroll_entry = frappe.get_doc("Payroll Entry", args.payroll_entry)
+ salary_slips_exist_for = get_existing_salary_slips(employees, args)
+ count = 0
- else:
- salary_slips_not_created.append(emp)
+ for emp in employees:
+ if emp not in salary_slips_exist_for:
+ args.update({"doctype": "Salary Slip", "employee": emp})
+ frappe.get_doc(args).insert()
- payroll_entry = frappe.get_doc("Payroll Entry", args.payroll_entry)
- payroll_entry.db_set("salary_slips_created", 1)
- payroll_entry.notify_update()
+ count += 1
+ if publish_progress:
+ frappe.publish_progress(
+ count * 100 / len(set(employees) - set(salary_slips_exist_for)),
+ title=_("Creating Salary Slips..."),
+ )
- if salary_slips_not_created:
+ payroll_entry.db_set({"status": "Submitted", "salary_slips_created": 1, "error_message": ""})
+
+ if salary_slips_exist_for:
+ frappe.msgprint(
+ _(
+ "Salary Slips already exist for employees {}, and will not be processed by this payroll."
+ ).format(frappe.bold(", ".join(emp for emp in salary_slips_exist_for))),
+ title=_("Message"),
+ indicator="orange",
+ )
+
+ except Exception as e:
+ frappe.db.rollback()
+ log_payroll_failure("creation", payroll_entry, e)
+
+ finally:
+ frappe.db.commit() # nosemgrep
+ frappe.publish_realtime("completed_salary_slip_creation")
+
+
+def show_payroll_submission_status(submitted, unsubmitted, payroll_entry):
+ if not submitted and not unsubmitted:
frappe.msgprint(
_(
- "Salary Slips already exists for employees {}, and will not be processed by this payroll."
- ).format(frappe.bold(", ".join([emp for emp in salary_slips_not_created]))),
- title=_("Message"),
- indicator="orange",
+ "No salary slip found to submit for the above selected criteria OR salary slip already submitted"
+ )
+ )
+ elif submitted and not unsubmitted:
+ frappe.msgprint(
+ _("Salary Slips submitted for period from {0} to {1}").format(
+ payroll_entry.start_date, payroll_entry.end_date
+ )
+ )
+ elif unsubmitted:
+ frappe.msgprint(
+ _("Could not submit some Salary Slips: {}").format(
+ ", ".join(get_link_to_form("Salary Slip", entry) for entry in unsubmitted)
+ )
)
@@ -827,45 +921,41 @@ def get_existing_salary_slips(employees, args):
def submit_salary_slips_for_employees(payroll_entry, salary_slips, publish_progress=True):
- submitted_ss = []
- not_submitted_ss = []
- frappe.flags.via_payroll_entry = True
+ try:
+ submitted = []
+ unsubmitted = []
+ frappe.flags.via_payroll_entry = True
+ count = 0
- count = 0
- for ss in salary_slips:
- ss_obj = frappe.get_doc("Salary Slip", ss[0])
- if ss_obj.net_pay < 0:
- not_submitted_ss.append(ss[0])
- else:
- try:
- ss_obj.submit()
- submitted_ss.append(ss_obj)
- except frappe.ValidationError:
- not_submitted_ss.append(ss[0])
+ for entry in salary_slips:
+ salary_slip = frappe.get_doc("Salary Slip", entry[0])
+ if salary_slip.net_pay < 0:
+ unsubmitted.append(entry[0])
+ else:
+ try:
+ salary_slip.submit()
+ submitted.append(salary_slip)
+ except frappe.ValidationError:
+ unsubmitted.append(entry[0])
- count += 1
- if publish_progress:
- frappe.publish_progress(count * 100 / len(salary_slips), title=_("Submitting Salary Slips..."))
- if submitted_ss:
- payroll_entry.make_accrual_jv_entry()
- frappe.msgprint(
- _("Salary Slip submitted for period from {0} to {1}").format(ss_obj.start_date, ss_obj.end_date)
- )
+ count += 1
+ if publish_progress:
+ frappe.publish_progress(count * 100 / len(salary_slips), title=_("Submitting Salary Slips..."))
- payroll_entry.email_salary_slip(submitted_ss)
+ if submitted:
+ payroll_entry.make_accrual_jv_entry()
+ payroll_entry.email_salary_slip(submitted)
+ payroll_entry.db_set({"salary_slips_submitted": 1, "status": "Submitted", "error_message": ""})
- payroll_entry.db_set("salary_slips_submitted", 1)
- payroll_entry.notify_update()
+ show_payroll_submission_status(submitted, unsubmitted, payroll_entry)
- if not submitted_ss and not not_submitted_ss:
- frappe.msgprint(
- _(
- "No salary slip found to submit for the above selected criteria OR salary slip already submitted"
- )
- )
+ except Exception as e:
+ frappe.db.rollback()
+ log_payroll_failure("submission", payroll_entry, e)
- if not_submitted_ss:
- frappe.msgprint(_("Could not submit some Salary Slips"))
+ finally:
+ frappe.db.commit() # nosemgrep
+ frappe.publish_realtime("completed_salary_slip_submission")
frappe.flags.via_payroll_entry = False
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry_list.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry_list.js
new file mode 100644
index 00000000000..56390b79d8b
--- /dev/null
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry_list.js
@@ -0,0 +1,18 @@
+// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+// License: GNU General Public License v3. See license.txt
+
+// render
+frappe.listview_settings['Payroll Entry'] = {
+ has_indicator_for_draft: 1,
+ get_indicator: function(doc) {
+ var status_color = {
+ 'Draft': 'red',
+ 'Submitted': 'blue',
+ 'Queued': 'orange',
+ 'Failed': 'red',
+ 'Cancelled': 'red'
+
+ };
+ return [__(doc.status), status_color[doc.status], 'status,=,'+doc.status];
+ }
+};
diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
index fda0fcf8bee..0363a0c3de6 100644
--- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
@@ -5,6 +5,7 @@ import unittest
import frappe
from dateutil.relativedelta import relativedelta
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_months
import erpnext
@@ -22,10 +23,9 @@ from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_
from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_end_date, get_start_end_dates
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
create_account,
- get_salary_component_account,
make_deduction_salary_component,
make_earning_salary_component,
- make_employee_salary_slip,
+ set_salary_component_account,
)
from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
create_salary_structure_assignment,
@@ -35,13 +35,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
test_dependencies = ["Holiday List"]
-class TestPayrollEntry(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- frappe.db.set_value(
- "Company", erpnext.get_default_company(), "default_holiday_list", "_Test Holiday List"
- )
-
+class TestPayrollEntry(FrappeTestCase):
def setUp(self):
for dt in [
"Salary Slip",
@@ -52,81 +46,72 @@ class TestPayrollEntry(unittest.TestCase):
"Salary Structure Assignment",
"Payroll Employee Detail",
"Additional Salary",
+ "Loan",
]:
- frappe.db.sql("delete from `tab%s`" % dt)
+ frappe.db.delete(dt)
make_earning_salary_component(setup=True, company_list=["_Test Company"])
make_deduction_salary_component(setup=True, test_tax=False, company_list=["_Test Company"])
+ frappe.db.set_value("Company", "_Test Company", "default_holiday_list", "_Test Holiday List")
frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 0)
- def test_payroll_entry(self): # pylint: disable=no-self-use
- company = erpnext.get_default_company()
- for data in frappe.get_all("Salary Component", fields=["name"]):
- if not frappe.db.get_value(
- "Salary Component Account", {"parent": data.name, "company": company}, "name"
- ):
- get_salary_component_account(data.name)
-
- employee = frappe.db.get_value("Employee", {"company": company})
- company_doc = frappe.get_doc("Company", company)
- make_salary_structure(
- "_Test Salary Structure",
- "Monthly",
- employee,
- company=company,
- currency=company_doc.default_currency,
+ # set default payable account
+ default_account = frappe.db.get_value(
+ "Company", "_Test Company", "default_payroll_payable_account"
)
- dates = get_start_end_dates("Monthly", nowdate())
- if not frappe.db.get_value(
- "Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date}
- ):
- make_payroll_entry(
- start_date=dates.start_date,
- end_date=dates.end_date,
- payable_account=company_doc.default_payroll_payable_account,
- currency=company_doc.default_currency,
+ if not default_account or default_account != "_Test Payroll Payable - _TC":
+ create_account(
+ account_name="_Test Payroll Payable",
+ company="_Test Company",
+ parent_account="Current Liabilities - _TC",
+ account_type="Payable",
+ )
+ frappe.db.set_value(
+ "Company", "_Test Company", "default_payroll_payable_account", "_Test Payroll Payable - _TC"
)
- def test_multi_currency_payroll_entry(self): # pylint: disable=no-self-use
- company = erpnext.get_default_company()
- employee = make_employee("test_muti_currency_employee@payroll.com", company=company)
- for data in frappe.get_all("Salary Component", fields=["name"]):
- if not frappe.db.get_value(
- "Salary Component Account", {"parent": data.name, "company": company}, "name"
- ):
- get_salary_component_account(data.name)
+ def test_payroll_entry(self):
+ company = frappe.get_doc("Company", "_Test Company")
+ employee = frappe.db.get_value("Employee", {"company": "_Test Company"})
+ setup_salary_structure(employee, company)
- company_doc = frappe.get_doc("Company", company)
- salary_structure = make_salary_structure(
- "_Test Multi Currency Salary Structure", "Monthly", company=company, currency="USD"
+ dates = get_start_end_dates("Monthly", nowdate())
+ make_payroll_entry(
+ start_date=dates.start_date,
+ end_date=dates.end_date,
+ payable_account=company.default_payroll_payable_account,
+ currency=company.default_currency,
+ company=company.name,
)
- create_salary_structure_assignment(
- employee, salary_structure.name, company=company, currency="USD"
- )
- frappe.db.sql(
- """delete from `tabSalary Slip` where employee=%s""",
- (frappe.db.get_value("Employee", {"user_id": "test_muti_currency_employee@payroll.com"})),
- )
- salary_slip = get_salary_slip(
- "test_muti_currency_employee@payroll.com", "Monthly", "_Test Multi Currency Salary Structure"
+
+ def test_multi_currency_payroll_entry(self):
+ company = frappe.get_doc("Company", "_Test Company")
+ employee = make_employee(
+ "test_muti_currency_employee@payroll.com", company=company.name, department="Accounts - _TC"
)
+ salary_structure = "_Test Multi Currency Salary Structure"
+ setup_salary_structure(employee, company, "USD", salary_structure)
+
dates = get_start_end_dates("Monthly", nowdate())
payroll_entry = make_payroll_entry(
start_date=dates.start_date,
end_date=dates.end_date,
- payable_account=company_doc.default_payroll_payable_account,
+ payable_account=company.default_payroll_payable_account,
currency="USD",
exchange_rate=70,
+ company=company.name,
+ cost_center="Main - _TC",
)
payroll_entry.make_payment_entry()
- salary_slip.load_from_db()
+ salary_slip = frappe.db.get_value("Salary Slip", {"payroll_entry": payroll_entry.name}, "name")
+ salary_slip = frappe.get_doc("Salary Slip", salary_slip)
+ payroll_entry.reload()
payroll_je = salary_slip.journal_entry
if payroll_je:
payroll_je_doc = frappe.get_doc("Journal Entry", payroll_je)
-
self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_debit)
self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_credit)
@@ -139,27 +124,15 @@ class TestPayrollEntry(unittest.TestCase):
(payroll_entry.name),
as_dict=1,
)
-
self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_debit)
self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_credit)
- def test_payroll_entry_with_employee_cost_center(self): # pylint: disable=no-self-use
- for data in frappe.get_all("Salary Component", fields=["name"]):
- if not frappe.db.get_value(
- "Salary Component Account", {"parent": data.name, "company": "_Test Company"}, "name"
- ):
- get_salary_component_account(data.name)
-
+ def test_payroll_entry_with_employee_cost_center(self):
if not frappe.db.exists("Department", "cc - _TC"):
frappe.get_doc(
{"doctype": "Department", "department_name": "cc", "company": "_Test Company"}
).insert()
- frappe.db.sql("""delete from `tabEmployee` where employee_name='test_employee1@example.com' """)
- frappe.db.sql("""delete from `tabEmployee` where employee_name='test_employee2@example.com' """)
- frappe.db.sql("""delete from `tabSalary Structure` where name='_Test Salary Structure 1' """)
- frappe.db.sql("""delete from `tabSalary Structure` where name='_Test Salary Structure 2' """)
-
employee1 = make_employee(
"test_employee1@example.com",
payroll_cost_center="_Test Cost Center - _TC",
@@ -170,38 +143,15 @@ class TestPayrollEntry(unittest.TestCase):
"test_employee2@example.com", department="cc - _TC", company="_Test Company"
)
- if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
- create_account(
- account_name="_Test Payroll Payable",
- company="_Test Company",
- parent_account="Current Liabilities - _TC",
- account_type="Payable",
- )
+ company = frappe.get_doc("Company", "_Test Company")
+ setup_salary_structure(employee1, company)
- if (
- not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account")
- or frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account")
- != "_Test Payroll Payable - _TC"
- ):
- frappe.db.set_value(
- "Company", "_Test Company", "default_payroll_payable_account", "_Test Payroll Payable - _TC"
- )
- currency = frappe.db.get_value("Company", "_Test Company", "default_currency")
-
- make_salary_structure(
- "_Test Salary Structure 1",
- "Monthly",
- employee1,
- company="_Test Company",
- currency=currency,
- test_tax=False,
- )
ss = make_salary_structure(
"_Test Salary Structure 2",
"Monthly",
employee2,
company="_Test Company",
- currency=currency,
+ currency=company.default_currency,
test_tax=False,
)
@@ -220,42 +170,38 @@ class TestPayrollEntry(unittest.TestCase):
ssa_doc.append(
"payroll_cost_centers", {"cost_center": "_Test Cost Center 2 - _TC", "percentage": 40}
)
-
ssa_doc.save()
dates = get_start_end_dates("Monthly", nowdate())
- if not frappe.db.get_value(
- "Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date}
- ):
- pe = make_payroll_entry(
- start_date=dates.start_date,
- end_date=dates.end_date,
- payable_account="_Test Payroll Payable - _TC",
- currency=frappe.db.get_value("Company", "_Test Company", "default_currency"),
- department="cc - _TC",
- company="_Test Company",
- payment_account="Cash - _TC",
- cost_center="Main - _TC",
- )
- je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry")
- je_entries = frappe.db.sql(
- """
- select account, cost_center, debit, credit
- from `tabJournal Entry Account`
- where parent=%s
- order by account, cost_center
- """,
- je,
- )
- expected_je = (
- ("_Test Payroll Payable - _TC", "Main - _TC", 0.0, 155600.0),
- ("Salary - _TC", "_Test Cost Center - _TC", 124800.0, 0.0),
- ("Salary - _TC", "_Test Cost Center 2 - _TC", 31200.0, 0.0),
- ("Salary Deductions - _TC", "_Test Cost Center - _TC", 0.0, 320.0),
- ("Salary Deductions - _TC", "_Test Cost Center 2 - _TC", 0.0, 80.0),
- )
+ pe = make_payroll_entry(
+ start_date=dates.start_date,
+ end_date=dates.end_date,
+ payable_account="_Test Payroll Payable - _TC",
+ currency=frappe.db.get_value("Company", "_Test Company", "default_currency"),
+ department="cc - _TC",
+ company="_Test Company",
+ payment_account="Cash - _TC",
+ cost_center="Main - _TC",
+ )
+ je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry")
+ je_entries = frappe.db.sql(
+ """
+ select account, cost_center, debit, credit
+ from `tabJournal Entry Account`
+ where parent=%s
+ order by account, cost_center
+ """,
+ je,
+ )
+ expected_je = (
+ ("_Test Payroll Payable - _TC", "Main - _TC", 0.0, 155600.0),
+ ("Salary - _TC", "_Test Cost Center - _TC", 124800.0, 0.0),
+ ("Salary - _TC", "_Test Cost Center 2 - _TC", 31200.0, 0.0),
+ ("Salary Deductions - _TC", "_Test Cost Center - _TC", 0.0, 320.0),
+ ("Salary Deductions - _TC", "_Test Cost Center 2 - _TC", 0.0, 80.0),
+ )
- self.assertEqual(je_entries, expected_je)
+ self.assertEqual(je_entries, expected_je)
def test_get_end_date(self):
self.assertEqual(get_end_date("2017-01-01", "monthly"), {"end_date": "2017-01-31"})
@@ -268,31 +214,22 @@ class TestPayrollEntry(unittest.TestCase):
self.assertEqual(get_end_date("2017-02-15", "daily"), {"end_date": "2017-02-15"})
def test_loan(self):
- branch = "Test Employee Branch"
- applicant = make_employee("test_employee@loan.com", company="_Test Company")
company = "_Test Company"
- holiday_list = make_holiday("test holiday for loan")
-
- company_doc = frappe.get_doc("Company", company)
- if not company_doc.default_payroll_payable_account:
- company_doc.default_payroll_payable_account = frappe.db.get_value(
- "Account", {"company": company, "root_type": "Liability", "account_type": ""}, "name"
- )
- company_doc.save()
+ branch = "Test Employee Branch"
if not frappe.db.exists("Branch", branch):
frappe.get_doc({"doctype": "Branch", "branch": branch}).insert()
+ holiday_list = make_holiday("test holiday for loan")
- employee_doc = frappe.get_doc("Employee", applicant)
- employee_doc.branch = branch
- employee_doc.holiday_list = holiday_list
- employee_doc.save()
+ applicant = make_employee(
+ "test_employee@loan.com", company="_Test Company", branch=branch, holiday_list=holiday_list
+ )
+ company_doc = frappe.get_doc("Company", company)
- salary_structure = "Test Salary Structure for Loan"
make_salary_structure(
- salary_structure,
+ "Test Salary Structure for Loan",
"Monthly",
- employee=employee_doc.name,
+ employee=applicant,
company="_Test Company",
currency=company_doc.default_currency,
)
@@ -353,11 +290,110 @@ class TestPayrollEntry(unittest.TestCase):
self.assertEqual(row.principal_amount, principal_amount)
self.assertEqual(row.total_payment, interest_amount + principal_amount)
- if salary_slip.docstatus == 0:
- frappe.delete_doc("Salary Slip", name)
+ def test_salary_slip_operation_queueing(self):
+ company = "_Test Company"
+ company_doc = frappe.get_doc("Company", company)
+ employee = make_employee("test_employee@payroll.com", company=company)
+ setup_salary_structure(employee, company_doc)
+
+ # enqueue salary slip creation via payroll entry
+ # Payroll Entry status should change to Queued
+ dates = get_start_end_dates("Monthly", nowdate())
+ payroll_entry = get_payroll_entry(
+ start_date=dates.start_date,
+ end_date=dates.end_date,
+ payable_account=company_doc.default_payroll_payable_account,
+ currency=company_doc.default_currency,
+ company=company_doc.name,
+ cost_center="Main - _TC",
+ )
+ frappe.flags.enqueue_payroll_entry = True
+ payroll_entry.submit()
+ payroll_entry.reload()
+
+ self.assertEqual(payroll_entry.status, "Queued")
+ frappe.flags.enqueue_payroll_entry = False
+
+ def test_salary_slip_operation_failure(self):
+ company = "_Test Company"
+ company_doc = frappe.get_doc("Company", company)
+ employee = make_employee("test_employee@payroll.com", company=company)
+
+ salary_structure = make_salary_structure(
+ "_Test Salary Structure",
+ "Monthly",
+ employee,
+ company=company,
+ currency=company_doc.default_currency,
+ )
+
+ # reset account in component to test submission failure
+ component = frappe.get_doc("Salary Component", salary_structure.earnings[0].salary_component)
+ component.accounts = []
+ component.save()
+
+ # salary slip submission via payroll entry
+ # Payroll Entry status should change to Failed because of the missing account setup
+ dates = get_start_end_dates("Monthly", nowdate())
+ payroll_entry = get_payroll_entry(
+ start_date=dates.start_date,
+ end_date=dates.end_date,
+ payable_account=company_doc.default_payroll_payable_account,
+ currency=company_doc.default_currency,
+ company=company_doc.name,
+ cost_center="Main - _TC",
+ )
+
+ # set employee as Inactive to check creation failure
+ frappe.db.set_value("Employee", employee, "status", "Inactive")
+ payroll_entry.submit()
+ payroll_entry.reload()
+ self.assertEqual(payroll_entry.status, "Failed")
+ self.assertIsNotNone(payroll_entry.error_message)
+
+ frappe.db.set_value("Employee", employee, "status", "Active")
+ payroll_entry.submit()
+ payroll_entry.submit_salary_slips()
+
+ payroll_entry.reload()
+ self.assertEqual(payroll_entry.status, "Failed")
+ self.assertIsNotNone(payroll_entry.error_message)
+
+ # set accounts
+ for data in frappe.get_all("Salary Component", pluck="name"):
+ set_salary_component_account(data, company_list=[company])
+
+ # Payroll Entry successful, status should change to Submitted
+ payroll_entry.submit_salary_slips()
+ payroll_entry.reload()
+
+ self.assertEqual(payroll_entry.status, "Submitted")
+ self.assertEqual(payroll_entry.error_message, "")
+
+ def test_payroll_entry_status(self):
+ company = "_Test Company"
+ company_doc = frappe.get_doc("Company", company)
+ employee = make_employee("test_employee@payroll.com", company=company)
+
+ setup_salary_structure(employee, company_doc)
+
+ dates = get_start_end_dates("Monthly", nowdate())
+ payroll_entry = get_payroll_entry(
+ start_date=dates.start_date,
+ end_date=dates.end_date,
+ payable_account=company_doc.default_payroll_payable_account,
+ currency=company_doc.default_currency,
+ company=company_doc.name,
+ cost_center="Main - _TC",
+ )
+ payroll_entry.submit()
+ self.assertEqual(payroll_entry.status, "Submitted")
+
+ payroll_entry.cancel()
+ self.assertEqual(payroll_entry.status, "Cancelled")
-def make_payroll_entry(**args):
+def get_payroll_entry(**args):
args = frappe._dict(args)
payroll_entry = frappe.new_doc("Payroll Entry")
@@ -380,8 +416,17 @@ def make_payroll_entry(**args):
payroll_entry.payment_account = args.payment_account
payroll_entry.fill_employee_details()
- payroll_entry.save()
- payroll_entry.create_salary_slips()
+ payroll_entry.insert()
+
+ # Commit so that the first salary slip creation failure does not rollback the Payroll Entry insert.
+ frappe.db.commit() # nosemgrep
+
+ return payroll_entry
+
+
+def make_payroll_entry(**args):
+ payroll_entry = get_payroll_entry(**args)
+ payroll_entry.submit()
payroll_entry.submit_salary_slips()
if payroll_entry.get_sal_slip_list(ss_status=1):
payroll_entry.make_payment_entry()
@@ -423,10 +468,17 @@ def make_holiday(holiday_list_name):
return holiday_list_name
-def get_salary_slip(user, period, salary_structure):
- salary_slip = make_employee_salary_slip(user, period, salary_structure)
- salary_slip.exchange_rate = 70
- salary_slip.calculate_net_pay()
- salary_slip.db_update()
+def setup_salary_structure(employee, company_doc, currency=None, salary_structure=None):
+ for data in frappe.get_all("Salary Component", pluck="name"):
+ if not frappe.db.get_value(
+ "Salary Component Account", {"parent": data, "company": company_doc.name}, "name"
+ ):
+ set_salary_component_account(data)
- return salary_slip
+ make_salary_structure(
+ salary_structure or "_Test Salary Structure",
+ "Monthly",
+ employee,
+ company=company_doc.name,
+ currency=(currency or company_doc.default_currency),
+ )
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index 6a7f72b0132..6a35985e64c 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -29,6 +29,9 @@ from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
calculate_amounts,
create_repayment_entry,
)
+from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import (
+ process_loan_interest_accrual_for_term_loans,
+)
from erpnext.payroll.doctype.additional_salary.additional_salary import get_additional_salaries
from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import (
get_benefit_component_amount,
@@ -116,10 +119,10 @@ class SalarySlip(TransactionBase):
self.update_payment_status_for_gratuity()
def update_payment_status_for_gratuity(self):
- add_salary = frappe.db.get_all(
+ additional_salary = frappe.db.get_all(
"Additional Salary",
filters={
- "payroll_date": ("BETWEEN", [self.start_date, self.end_date]),
+ "payroll_date": ("between", [self.start_date, self.end_date]),
"employee": self.employee,
"ref_doctype": "Gratuity",
"docstatus": 1,
@@ -128,10 +131,10 @@ class SalarySlip(TransactionBase):
limit=1,
)
- if len(add_salary):
+ if additional_salary:
status = "Paid" if self.docstatus == 1 else "Unpaid"
- if add_salary[0].name in [data.additional_salary for data in self.earnings]:
- frappe.db.set_value("Gratuity", add_salary.ref_docname, "status", status)
+ if additional_salary[0].name in [entry.additional_salary for entry in self.earnings]:
+ frappe.db.set_value("Gratuity", additional_salary[0].ref_docname, "status", status)
def on_cancel(self):
self.set_status()
@@ -462,37 +465,14 @@ class SalarySlip(TransactionBase):
)
for d in range(working_days):
- dt = add_days(cstr(getdate(self.start_date)), d)
- leave = frappe.db.sql(
- """
- SELECT t1.name,
- CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date)
- THEN t1.half_day else 0 END,
- t2.is_ppl,
- t2.fraction_of_daily_salary_per_leave
- FROM `tabLeave Application` t1, `tabLeave Type` t2
- WHERE t2.name = t1.leave_type
- AND (t2.is_lwp = 1 or t2.is_ppl = 1)
- AND t1.docstatus = 1
- AND t1.employee = %(employee)s
- AND ifnull(t1.salary_slip, '') = ''
- AND CASE
- WHEN t2.include_holiday != 1
- THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date
- WHEN t2.include_holiday
- THEN %(dt)s between from_date and to_date
- END
- """.format(
- holidays
- ),
- {"employee": self.employee, "dt": dt},
- )
+ date = add_days(cstr(getdate(self.start_date)), d)
+ leave = get_lwp_or_ppl_for_date(date, self.employee, holidays)
if leave:
equivalent_lwp_count = 0
- is_half_day_leave = cint(leave[0][1])
- is_partially_paid_leave = cint(leave[0][2])
- fraction_of_daily_salary_per_leave = flt(leave[0][3])
+ is_half_day_leave = cint(leave[0].is_half_day)
+ is_partially_paid_leave = cint(leave[0].is_ppl)
+ fraction_of_daily_salary_per_leave = flt(leave[0].fraction_of_daily_salary_per_leave)
equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1
@@ -1364,9 +1344,9 @@ class SalarySlip(TransactionBase):
self.total_loan_repayment += payment.total_payment
def get_loan_details(self):
- return frappe.get_all(
+ loan_details = frappe.get_all(
"Loan",
- fields=["name", "interest_income_account", "loan_account", "loan_type"],
+ fields=["name", "interest_income_account", "loan_account", "loan_type", "is_term_loan"],
filters={
"applicant": self.employee,
"docstatus": 1,
@@ -1375,6 +1355,15 @@ class SalarySlip(TransactionBase):
},
)
+ if loan_details:
+ for loan in loan_details:
+ if loan.is_term_loan:
+ process_loan_interest_accrual_for_term_loans(
+ posting_date=self.posting_date, loan_type=loan.loan_type, loan=loan.name
+ )
+
+ return loan_details
+
def make_loan_repayment_entry(self):
payroll_payable_account = get_payroll_payable_account(self.company, self.payroll_entry)
for loan in self.loans:
@@ -1730,3 +1719,46 @@ def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
except Exception as e:
frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e))
raise
+
+
+def get_lwp_or_ppl_for_date(date, employee, holidays):
+ LeaveApplication = frappe.qb.DocType("Leave Application")
+ LeaveType = frappe.qb.DocType("Leave Type")
+
+ is_half_day = (
+ frappe.qb.terms.Case()
+ .when(
+ (
+ (LeaveApplication.half_day_date == date)
+ | (LeaveApplication.from_date == LeaveApplication.to_date)
+ ),
+ LeaveApplication.half_day,
+ )
+ .else_(0)
+ ).as_("is_half_day")
+
+ query = (
+ frappe.qb.from_(LeaveApplication)
+ .inner_join(LeaveType)
+ .on((LeaveType.name == LeaveApplication.leave_type))
+ .select(
+ LeaveApplication.name,
+ LeaveType.is_ppl,
+ LeaveType.fraction_of_daily_salary_per_leave,
+ (is_half_day),
+ )
+ .where(
+ (((LeaveType.is_lwp == 1) | (LeaveType.is_ppl == 1)))
+ & (LeaveApplication.docstatus == 1)
+ & (LeaveApplication.status == "Approved")
+ & (LeaveApplication.employee == employee)
+ & ((LeaveApplication.salary_slip.isnull()) | (LeaveApplication.salary_slip == ""))
+ & ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date))
+ )
+ )
+
+ # if it's a holiday only include if leave type has "include holiday" enabled
+ if date in holidays:
+ query = query.where((LeaveType.include_holiday == "1"))
+
+ return query.run(as_dict=True)
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 1bc37419228..a8b6bb5714b 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -49,7 +49,7 @@ class TestSalarySlip(unittest.TestCase):
"Payroll Settings", {"payroll_based_on": "Attendance", "daily_wages_fraction_for_half_day": 0.75}
)
def test_payment_days_based_on_attendance(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
emp_id = make_employee("test_payment_days_based_on_attendance@salary.com")
frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
@@ -128,7 +128,7 @@ class TestSalarySlip(unittest.TestCase):
},
)
def test_payment_days_for_mid_joinee_including_holidays(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -196,7 +196,7 @@ class TestSalarySlip(unittest.TestCase):
# tests mid month joining and relieving along with unmarked days
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -236,7 +236,7 @@ class TestSalarySlip(unittest.TestCase):
def test_payment_days_for_mid_joinee_excluding_holidays(self):
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -267,7 +267,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"payroll_based_on": "Leave"})
def test_payment_days_based_on_leave_application(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com")
frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
@@ -366,7 +366,7 @@ class TestSalarySlip(unittest.TestCase):
salary_slip.submit()
salary_slip.reload()
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
days_in_month = no_of_days[0]
no_of_holidays = no_of_days[1]
@@ -441,7 +441,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 1})
def test_salary_slip_with_holidays_included(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
make_employee("test_salary_slip_with_holidays_included@salary.com")
frappe.db.set_value(
"Employee",
@@ -473,7 +473,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 0})
def test_salary_slip_with_holidays_excluded(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
make_employee("test_salary_slip_with_holidays_excluded@salary.com")
frappe.db.set_value(
"Employee",
@@ -510,7 +510,7 @@ class TestSalarySlip(unittest.TestCase):
create_salary_structure_assignment,
)
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
# set joinng date in the same month
employee = make_employee("test_payment_days@salary.com")
@@ -984,20 +984,21 @@ class TestSalarySlip(unittest.TestCase):
activity_type.wage_rate = 25
activity_type.save()
- def get_no_of_days(self):
- no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month)
- no_of_holidays_in_month = len(
- [
- 1
- for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month)
- if i[6] != 0
- ]
- )
- return [no_of_days_in_month[1], no_of_holidays_in_month]
+def get_no_of_days():
+ no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month)
+ no_of_holidays_in_month = len(
+ [
+ 1
+ for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month)
+ if i[6] != 0
+ ]
+ )
+
+ return [no_of_days_in_month[1], no_of_holidays_in_month]
-def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
+def make_employee_salary_slip(user, payroll_frequency, salary_structure=None, posting_date=None):
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
if not salary_structure:
@@ -1008,7 +1009,11 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
)
salary_structure_doc = make_salary_structure(
- salary_structure, payroll_frequency, employee=employee.name, company=employee.company
+ salary_structure,
+ payroll_frequency,
+ employee=employee.name,
+ company=employee.company,
+ from_date=posting_date,
)
salary_slip_name = frappe.db.get_value(
"Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})}
@@ -1018,7 +1023,7 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
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.posting_date = posting_date or nowdate()
salary_slip.insert()
else:
salary_slip = frappe.get_doc("Salary Slip", salary_slip_name)
@@ -1046,10 +1051,10 @@ def make_salary_component(salary_components, test_tax, company_list=None):
doc.update(salary_component)
doc.insert()
- get_salary_component_account(doc, company_list)
+ set_salary_component_account(doc, company_list)
-def get_salary_component_account(sal_comp, company_list=None):
+def set_salary_component_account(sal_comp, company_list=None):
company = erpnext.get_default_company()
if company_list and company not in company_list:
@@ -1132,6 +1137,7 @@ def make_earning_salary_component(
"pay_against_benefit_claim": 0,
"type": "Earning",
"max_benefit_amount": 15000,
+ "depends_on_payment_days": 1,
},
]
)
diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.py b/erpnext/payroll/doctype/salary_structure/salary_structure.py
index fa36b7ab2d5..edf17dbfb13 100644
--- a/erpnext/payroll/doctype/salary_structure/salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/salary_structure.py
@@ -253,6 +253,7 @@ def make_salary_slip(
source_name,
target_doc=None,
employee=None,
+ posting_date=None,
as_print=False,
print_format=None,
for_preview=0,
@@ -269,6 +270,9 @@ def make_salary_slip(
target.designation = employee_details.designation
target.department = employee_details.department
+ if posting_date:
+ target.posting_date = posting_date
+
target.run_method("process_salary_structure", for_preview=for_preview)
doc = get_mapped_doc(
diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
index e9b5ed2261d..8cc2ea33142 100644
--- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
@@ -169,9 +169,6 @@ def make_salary_structure(
payroll_period=None,
include_flexi_benefits=False,
):
- if test_tax:
- frappe.db.sql("""delete from `tabSalary Structure` where name=%s""", (salary_structure))
-
if frappe.db.exists("Salary Structure", salary_structure):
frappe.db.delete("Salary Structure", salary_structure)
@@ -230,9 +227,12 @@ def create_salary_structure_assignment(
company=None,
currency=erpnext.get_default_currency(),
payroll_period=None,
+ base=None,
+ allow_duplicate=False,
):
-
- if frappe.db.exists("Salary Structure Assignment", {"employee": employee}):
+ if not allow_duplicate and frappe.db.exists(
+ "Salary Structure Assignment", {"employee": employee}
+ ):
frappe.db.sql("""delete from `tabSalary Structure Assignment` where employee=%s""", (employee))
if not payroll_period:
@@ -245,7 +245,7 @@ def create_salary_structure_assignment(
salary_structure_assignment = frappe.new_doc("Salary Structure Assignment")
salary_structure_assignment.employee = employee
- salary_structure_assignment.base = 50000
+ salary_structure_assignment.base = base or 50000
salary_structure_assignment.variable = 5000
if not from_date:
diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json
index 1cda0a08c47..1790da44d6d 100644
--- a/erpnext/projects/doctype/project/project.json
+++ b/erpnext/projects/doctype/project/project.json
@@ -234,7 +234,7 @@
},
{
"fieldname": "actual_start_date",
- "fieldtype": "Data",
+ "fieldtype": "Date",
"label": "Actual Start Date (via Time Sheet)",
"read_only": 1
},
@@ -458,7 +458,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
- "modified": "2022-01-29 13:58:27.712714",
+ "modified": "2022-05-25 22:45:06.108499",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
@@ -504,4 +504,4 @@
"timeline_field": "customer",
"title_field": "project_name",
"track_seen": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index 58eb8916002..a5b7699040b 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -74,6 +74,7 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac
me.frm.set_query('supplier_address', erpnext.queries.address_query);
me.frm.set_query('billing_address', erpnext.queries.company_address_query);
+ erpnext.accounts.dimensions.setup_dimension_filters(me.frm, me.frm.doctype);
if(this.frm.fields_dict.supplier) {
this.frm.set_query("supplier", function() {
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 3dd11f69a76..16b0b4a866f 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -789,11 +789,23 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if(this.frm.doc.is_pos && (update_paid_amount===undefined || update_paid_amount)) {
$.each(this.frm.doc['payments'] || [], function(index, data) {
if(data.default && payment_status && total_amount_to_pay > 0) {
- let base_amount = flt(total_amount_to_pay, precision("base_amount", data));
+ let base_amount, amount;
+
+ if (me.frm.doc.party_account_currency == me.frm.doc.currency) {
+ // if customer/supplier currency is same as company currency
+ // total_amount_to_pay is already in customer/supplier currency
+ // so base_amount has to be calculated using total_amount_to_pay
+ base_amount = flt(total_amount_to_pay * me.frm.doc.conversion_rate, precision("base_amount", data));
+ amount = flt(total_amount_to_pay, precision("amount", data));
+ } else {
+ base_amount = flt(total_amount_to_pay, precision("base_amount", data));
+ amount = flt(total_amount_to_pay / me.frm.doc.conversion_rate, precision("amount", data));
+ }
+
frappe.model.set_value(data.doctype, data.name, "base_amount", base_amount);
- let amount = flt(total_amount_to_pay / me.frm.doc.conversion_rate, precision("amount", data));
frappe.model.set_value(data.doctype, data.name, "amount", amount);
payment_status = false;
+
} else if(me.frm.doc.paid_amount) {
frappe.model.set_value(data.doctype, data.name, "amount", 0.0);
}
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 05a401bdeea..de93c82ef2c 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -423,7 +423,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
item.barcode = null;
- if(item.item_code || item.barcode || item.serial_no) {
+ if(item.item_code || item.serial_no) {
if(!this.validate_company_and_party()) {
this.frm.fields_dict["items"].grid.grid_rows[item.idx - 1].remove();
} else {
@@ -463,6 +463,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
stock_qty: item.stock_qty,
conversion_factor: item.conversion_factor,
weight_per_unit: item.weight_per_unit,
+ uom: item.uom,
weight_uom: item.weight_uom,
manufacturer: item.manufacturer,
stock_uom: item.stock_uom,
@@ -526,12 +527,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
if(!d[k]) d[k] = v;
});
- if (d.__disable_batch_serial_selector) {
- // reset for future use.
- d.__disable_batch_serial_selector = false;
- return;
- }
-
if (d.has_batch_no && d.has_serial_no) {
d.batch_no = undefined;
}
@@ -944,7 +939,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} else {
// company currency and doc currency is same
// this will prevent unnecessary conversion rate triggers
- this.frm.set_value("conversion_rate", 1.0);
+ if(this.frm.doc.currency === this.get_company_currency()) {
+ this.frm.set_value("conversion_rate", 1.0);
+ } else {
+ this.conversion_rate();
+ }
}
}
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index e8db0976532..01710f1e41a 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -125,7 +125,7 @@ $.extend(erpnext.utils, {
},
add_indicator_for_multicompany: function(frm, info) {
- frm.dashboard.stats_area.removeClass('hidden');
+ frm.dashboard.stats_area.show();
frm.dashboard.stats_area_row.addClass('flex');
frm.dashboard.stats_area_row.css('flex-wrap', 'wrap');
diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js
index d3781185646..a6bff2c148d 100644
--- a/erpnext/public/js/utils/barcode_scanner.js
+++ b/erpnext/public/js/utils/barcode_scanner.js
@@ -9,6 +9,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
this.barcode_field = opts.barcode_field || "barcode";
this.serial_no_field = opts.serial_no_field || "serial_no";
this.batch_no_field = opts.batch_no_field || "batch_no";
+ this.uom_field = opts.uom_field || "uom";
this.qty_field = opts.qty_field || "qty";
// field name on row which defines max quantity to be scanned e.g. picklist
this.max_qty_field = opts.max_qty_field;
@@ -26,6 +27,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
// bar_code: "123456", // present if barcode was scanned
// batch_no: "LOT12", // present if batch was scanned
// serial_no: "987XYZ", // present if serial no was scanned
+ // uom: "Kg", // present if barcode UOM is different from default
// }
this.scan_api = opts.scan_api || "erpnext.stock.utils.scan_barcode";
}
@@ -35,6 +37,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
let me = this;
const input = this.scan_barcode_field.value;
+ this.scan_barcode_field.set_value("");
if (!input) {
return;
}
@@ -55,87 +58,92 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
return;
}
- const row = me.update_table(data);
- if (row) {
- resolve(row);
- }
- else {
- reject();
- }
+ me.update_table(data).then(row => {
+ row ? resolve(row) : reject();
+ });
});
});
}
update_table(data) {
- let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
+ return new Promise(resolve => {
+ let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
- const {item_code, barcode, batch_no, serial_no} = data;
+ const {item_code, barcode, batch_no, serial_no, uom} = data;
- let row = this.get_row_to_modify_on_scan(item_code, batch_no);
+ let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom);
- if (!row) {
- if (this.dont_allow_new_row) {
- this.show_alert(__("Maximum quantity scanned for item {0}.", [item_code]), "red");
+ if (!row) {
+ if (this.dont_allow_new_row) {
+ this.show_alert(__("Maximum quantity scanned for item {0}.", [item_code]), "red");
+ this.clean_up();
+ return;
+ }
+
+ // add new row if new item/batch is scanned
+ row = frappe.model.add_child(this.frm.doc, cur_grid.doctype, this.items_table_name);
+ // trigger any row add triggers defined on child table.
+ this.frm.script_manager.trigger(`${this.items_table_name}_add`, row.doctype, row.name);
+ }
+
+ if (this.is_duplicate_serial_no(row, serial_no)) {
this.clean_up();
return;
}
- // add new row if new item/batch is scanned
- row = frappe.model.add_child(this.frm.doc, cur_grid.doctype, this.items_table_name);
- // trigger any row add triggers defined on child table.
- this.frm.script_manager.trigger(`${this.items_table_name}_add`, row.doctype, row.name);
- }
-
- if (this.is_duplicate_serial_no(row, serial_no)) {
- this.clean_up();
- return;
- }
-
- this.set_selector_trigger_flag(row, data);
- this.set_item(row, item_code).then(qty => {
- this.show_scan_message(row.idx, row.item_code, qty);
+ frappe.run_serially([
+ () => this.set_selector_trigger_flag(data),
+ () => this.set_item(row, item_code).then(qty => {
+ this.show_scan_message(row.idx, row.item_code, qty);
+ }),
+ () => this.set_barcode_uom(row, uom),
+ () => this.set_serial_no(row, serial_no),
+ () => this.set_batch_no(row, batch_no),
+ () => this.set_barcode(row, barcode),
+ () => this.clean_up(),
+ () => this.revert_selector_flag(),
+ () => resolve(row)
+ ]);
});
- this.set_serial_no(row, serial_no);
- this.set_batch_no(row, batch_no);
- this.set_barcode(row, barcode);
- this.clean_up();
- return row;
}
// batch and serial selector is reduandant when all info can be added by scan
// this flag on item row is used by transaction.js to avoid triggering selector
- set_selector_trigger_flag(row, data) {
+ set_selector_trigger_flag(data) {
const {batch_no, serial_no, has_batch_no, has_serial_no} = data;
const require_selecting_batch = has_batch_no && !batch_no;
const require_selecting_serial = has_serial_no && !serial_no;
if (!(require_selecting_batch || require_selecting_serial)) {
- row.__disable_batch_serial_selector = true;
+ frappe.flags.hide_serial_batch_dialog = true;
}
}
+ revert_selector_flag() {
+ frappe.flags.hide_serial_batch_dialog = false;
+ }
+
set_item(row, item_code) {
return new Promise(resolve => {
- const increment = (value = 1) => {
+ const increment = async (value = 1) => {
const item_data = {item_code: item_code};
item_data[this.qty_field] = Number((row[this.qty_field] || 0)) + Number(value);
- frappe.model.set_value(row.doctype, row.name, item_data);
+ await frappe.model.set_value(row.doctype, row.name, item_data);
+ return value;
};
if (this.prompt_qty) {
frappe.prompt(__("Please enter quantity for item {0}", [item_code]), ({value}) => {
- increment(value);
- resolve(value);
+ increment(value).then((value) => resolve(value));
});
} else {
- increment();
- resolve();
+ increment().then((value) => resolve(value));
}
});
}
- set_serial_no(row, serial_no) {
+ async set_serial_no(row, serial_no) {
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
const existing_serial_nos = row[this.serial_no_field];
let new_serial_nos = "";
@@ -145,19 +153,25 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
} else {
new_serial_nos = serial_no;
}
- frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos);
+ await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos);
}
}
- set_batch_no(row, batch_no) {
+ async set_barcode_uom(row, uom) {
+ if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) {
+ await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom);
+ }
+ }
+
+ async set_batch_no(row, batch_no) {
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
- frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no);
+ await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no);
}
}
- set_barcode(row, barcode) {
+ async set_barcode(row, barcode) {
if (barcode && frappe.meta.has_field(row.doctype, this.barcode_field)) {
- frappe.model.set_value(row.doctype, row.name, this.barcode_field, barcode);
+ await frappe.model.set_value(row.doctype, row.name, this.barcode_field, barcode);
}
}
@@ -179,7 +193,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
return is_duplicate;
}
- get_row_to_modify_on_scan(item_code, batch_no) {
+ get_row_to_modify_on_scan(item_code, batch_no, uom) {
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
// Check if batch is scanned and table has batch no field
@@ -188,10 +202,12 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
const matching_row = (row) => {
const item_match = row.item_code == item_code;
- const batch_match = row.batch_no == batch_no;
+ const batch_match = row[this.batch_no_field] == batch_no;
+ const uom_match = !uom || row[this.uom_field] == uom;
const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
return item_match
+ && uom_match
&& (!is_batch_no_scan || batch_match)
&& (!check_max_qty || qty_in_limit)
}
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 d6210abf80d..090697b0102 100644
--- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
+++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
@@ -148,7 +148,6 @@ class GSTR3BReport(Document):
FROM `tabPurchase Invoice` p , `tabPurchase Invoice Item` i
WHERE p.docstatus = 1 and p.name = i.parent
and p.is_opening = 'No'
- and p.gst_category != 'Registered Composition'
and (i.is_nil_exempt = 1 or i.is_non_gst = 1 or p.gst_category = 'Registered Composition') and
month(p.posting_date) = %s and year(p.posting_date) = %s
and p.company = %s and p.company_gstin = %s
@@ -245,11 +244,10 @@ 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, 0.0)
- self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get(
- "base_net_amount", 0
- )
+ 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)
@@ -336,7 +334,6 @@ class GSTR3BReport(Document):
def set_outward_taxable_supplies(self):
inter_state_supply_details = {}
-
for inv, items_based_on_rate in self.items_based_on_tax_rate.items():
gst_category = self.invoice_detail_map.get(inv, {}).get("gst_category")
place_of_supply = (
@@ -362,7 +359,6 @@ class GSTR3BReport(Document):
else:
self.report_dict["sup_details"]["osup_det"]["iamt"] += taxable_value * rate / 100
self.report_dict["sup_details"]["osup_det"]["txval"] += taxable_value
-
if (
gst_category in ["Unregistered", "Registered Composition", "UIN Holders"]
and self.gst_details.get("gst_state") != place_of_supply.split("-")[1]
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
index ea56d07d6da..580e6469e2c 100644
--- a/erpnext/regional/india/e_invoice/einvoice.js
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -11,7 +11,7 @@ erpnext.setup_einvoice_actions = (doctype) => {
if (!invoice_eligible) return;
- const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc;
+ const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, qrcode_image, __unsaved } = frm.doc;
const add_custom_button = (label, action) => {
if (!frm.custom_buttons[label]) {
@@ -149,84 +149,73 @@ erpnext.setup_einvoice_actions = (doctype) => {
}
if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) {
- const fields = [
- {
- "label": "Reason",
- "fieldname": "reason",
- "fieldtype": "Select",
- "reqd": 1,
- "default": "1-Duplicate",
- "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"]
- },
- {
- "label": "Remark",
- "fieldname": "remark",
- "fieldtype": "Data",
- "reqd": 1
- }
- ];
const action = () => {
- const d = new frappe.ui.Dialog({
- title: __('Cancel E-Way Bill'),
- fields: fields,
- primary_action: function() {
- const data = d.get_values();
+ // This confirm is added to just reduce unnecesory API calls. All required logic is implemented on server side.
+ frappe.confirm(
+ __("Have you cancelled e-way bill on the portal?"),
+ () => {
frappe.call({
- method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill',
- args: {
- doctype,
- docname: name,
- eway_bill: ewaybill,
- reason: data.reason.split('-')[0],
- remark: data.remark
- },
+ method: "erpnext.regional.india.e_invoice.utils.cancel_eway_bill",
+ args: { doctype, docname: name },
freeze: true,
- callback: () => {
- frappe.show_alert({
- message: __('E-Way Bill Cancelled successfully'),
- indicator: 'green'
- }, 7);
- frm.reload_doc();
- d.hide();
- },
- error: () => {
- frappe.show_alert({
- message: __('E-Way Bill was not Cancelled'),
- indicator: 'red'
- }, 7);
- d.hide();
- }
+ callback: () => frm.reload_doc(),
});
},
- primary_action_label: __('Submit')
- });
- d.show();
+ () => {
+ frappe.show_alert(
+ {
+ message: __(
+ "Please cancel e-way bill on the portal first."
+ ),
+ indicator: "orange",
+ },
+ 5
+ );
+ }
+ );
};
add_custom_button(__("Cancel E-Way Bill"), action);
}
if (irn && !irn_cancelled) {
- const action = () => {
- const dialog = frappe.msgprint({
- title: __("Generate QRCode"),
- message: __("Generate and attach QR Code using IRN?"),
- primary_action: {
- action: function() {
- frappe.call({
- method: 'erpnext.regional.india.e_invoice.utils.generate_qrcode',
- args: { doctype, docname: name },
- freeze: true,
- callback: () => frm.reload_doc() || dialog.hide(),
- error: () => dialog.hide()
- });
+ let is_qrcode_attached = false;
+ if (qrcode_image && frm.attachments) {
+ let attachments = frm.attachments.get_attachments();
+ if (attachments.length != 0) {
+ for (let i = 0; i < attachments.length; i++) {
+ if (attachments[i].file_url == qrcode_image) {
+ is_qrcode_attached = true;
+ break;
}
- },
+ }
+ }
+ }
+ if (!is_qrcode_attached) {
+ const action = () => {
+ if (frm.doc.__unsaved) {
+ frappe.throw(__('Please save the document to generate QRCode.'));
+ }
+ const dialog = frappe.msgprint({
+ title: __("Generate QRCode"),
+ message: __("Generate and attach QR Code using IRN?"),
+ primary_action: {
+ action: function() {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_qrcode',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: () => frm.reload_doc() || dialog.hide(),
+ error: () => dialog.hide()
+ });
+ }
+ },
primary_action_label: __('Yes')
});
dialog.show();
};
add_custom_button(__("Generate QRCode"), action);
}
+ }
}
});
};
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
index ed1002a129e..5eb14a5ddd3 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -55,6 +55,9 @@ def validate_eligibility(doc):
return False
invalid_company = not frappe.db.get_value("E Invoice User", {"company": doc.get("company")})
+ invalid_company_gstin = not frappe.db.get_value(
+ "E Invoice User", {"gstin": doc.get("company_gstin")}
+ )
invalid_supply_type = doc.get("gst_category") not in [
"Registered Regular",
"Registered Composition",
@@ -71,6 +74,7 @@ def validate_eligibility(doc):
if (
invalid_company
+ or invalid_company_gstin
or invalid_supply_type
or company_transaction
or no_taxes_applied
@@ -649,6 +653,8 @@ def make_einvoice(invoice):
try:
einvoice = safe_json_load(einvoice)
einvoice = santize_einvoice_fields(einvoice)
+ except json.JSONDecodeError:
+ raise
except Exception:
show_link_to_error_log(invoice, einvoice)
@@ -765,7 +771,9 @@ def safe_json_load(json_string):
frappe.throw(
_(
"Error in input data. Please check for any special characters near following input:
{}"
- ).format(snippet)
+ ).format(snippet),
+ title=_("Invalid JSON"),
+ exc=e,
)
@@ -797,7 +805,10 @@ class GSPConnector:
self.irn_details_url = self.base_url + "/enriched/ei/api/invoice/irn"
self.generate_irn_url = self.base_url + "/enriched/ei/api/invoice"
self.gstin_details_url = self.base_url + "/enriched/ei/api/master/gstin"
- self.cancel_ewaybill_url = self.base_url + "/enriched/ei/api/ewayapi"
+ # cancel_ewaybill_url will only work if user have bought ewb api from adaequare.
+ self.cancel_ewaybill_url = self.base_url + "/enriched/ewb/ewayapi?action=CANEWB"
+ # ewaybill_details_url + ?irn={irn_number} will provide eway bill number and details.
+ self.ewaybill_details_url = self.base_url + "/enriched/ei/api/ewaybill/irn"
self.generate_ewaybill_url = self.base_url + "/enriched/ei/api/ewaybill"
self.get_qrcode_url = self.base_url + "/enriched/ei/others/qr/image"
@@ -1005,13 +1016,32 @@ class GSPConnector:
return failed
def fetch_and_attach_qrcode_from_irn(self):
- qrcode = self.get_qrcode_from_irn(self.invoice.irn)
- if qrcode:
- qrcode_file = self.create_qr_code_file(qrcode)
- frappe.db.set_value("Sales Invoice", self.invoice.name, "qrcode_image", qrcode_file.file_url)
- frappe.msgprint(_("QR Code attached to the invoice"), alert=True)
+ is_qrcode_file_attached = self.invoice.qrcode_image and frappe.db.exists(
+ "File",
+ {
+ "attached_to_doctype": "Sales Invoice",
+ "attached_to_name": self.invoice.name,
+ "file_url": self.invoice.qrcode_image,
+ "attached_to_field": "qrcode_image",
+ },
+ )
+ if not is_qrcode_file_attached:
+ if self.invoice.signed_qr_code:
+ self.attach_qrcode_image()
+ frappe.db.set_value(
+ "Sales Invoice", self.invoice.name, "qrcode_image", self.invoice.qrcode_image
+ )
+ frappe.msgprint(_("QR Code attached to the invoice."), alert=True)
+ else:
+ qrcode = self.get_qrcode_from_irn(self.invoice.irn)
+ if qrcode:
+ qrcode_file = self.create_qr_code_file(qrcode)
+ frappe.db.set_value("Sales Invoice", self.invoice.name, "qrcode_image", qrcode_file.file_url)
+ frappe.msgprint(_("QR Code attached to the invoice."), alert=True)
+ else:
+ frappe.msgprint(_("QR Code not found for the IRN"), alert=True)
else:
- frappe.msgprint(_("QR Code not found for the IRN"), alert=True)
+ frappe.msgprint(_("QR Code is already Attached"), indicator="green", alert=True)
def get_qrcode_from_irn(self, irn):
import requests
@@ -1181,22 +1211,22 @@ class GSPConnector:
log_error(data)
self.raise_error(True)
- def cancel_eway_bill(self, eway_bill, reason, remark):
+ def get_ewb_details(self):
+ """
+ Get e-Waybill Details by IRN API documentaion for validation is not added yet.
+ https://einv-apisandbox.nic.in/version1.03/get-ewaybill-details-by-irn.html#validations
+ NOTE: if ewaybill Validity period lapsed or scanned by officer enroute (not tested yet) it will still return status as "ACT".
+ """
headers = self.get_headers()
- data = json.dumps({"ewbNo": eway_bill, "cancelRsnCode": reason, "cancelRmrk": remark}, indent=4)
- headers["username"] = headers["user_name"]
- try:
- res = self.make_request("post", self.cancel_ewaybill_url, headers, data)
- if res.get("success"):
- self.invoice.ewaybill = ""
- self.invoice.eway_bill_cancelled = 1
- self.invoice.flags.updater_reference = {
- "doctype": self.invoice.doctype,
- "docname": self.invoice.name,
- "label": _("E-Way Bill Cancelled - {}").format(remark),
- }
- self.update_invoice()
+ irn = self.invoice.irn
+ if not irn:
+ frappe.throw(_("IRN is mandatory to get E-Waybill Details. Please generate IRN first."))
+ try:
+ params = "?irn={irn}".format(irn=irn)
+ res = self.make_request("get", self.ewaybill_details_url + params, headers)
+ if res.get("success"):
+ return res.get("result")
else:
raise RequestFailed
@@ -1205,9 +1235,65 @@ class GSPConnector:
self.raise_error(errors=errors)
except Exception:
- log_error(data)
+ log_error()
self.raise_error(True)
+ def update_ewb_details(self, ewb_details=None):
+ # for any reason user chooses to generate eway bill using portal this will allow to update ewaybill details in the invoice.
+ if not self.invoice.irn:
+ frappe.throw(_("IRN is mandatory to update E-Waybill Details. Please generate IRN first."))
+ if not ewb_details:
+ ewb_details = self.get_ewb_details()
+ if ewb_details:
+ self.invoice.ewaybill = ewb_details.get("EwbNo")
+ self.invoice.eway_bill_validity = ewb_details.get("EwbValidTill")
+ self.invoice.eway_bill_cancelled = 0 if ewb_details.get("Status") == "ACT" else 1
+ self.update_invoice()
+
+ def cancel_eway_bill(self):
+ ewb_details = self.get_ewb_details()
+ if ewb_details:
+ ewb_no = str(ewb_details.get("EwbNo"))
+ ewb_status = ewb_details.get("Status")
+ if ewb_status == "CNL":
+ self.invoice.ewaybill = ""
+ self.invoice.eway_bill_cancelled = 1
+ self.invoice.flags.updater_reference = {
+ "doctype": self.invoice.doctype,
+ "docname": self.invoice.name,
+ "label": _("E-Way Bill Cancelled"),
+ }
+ self.update_invoice()
+ frappe.msgprint(
+ _("E-Way Bill Cancelled successfully"),
+ indicator="green",
+ alert=True,
+ )
+ elif ewb_status == "ACT" and self.invoice.ewaybill == ewb_no:
+ msg = _("E-Way Bill {} is still active.").format(bold(ewb_no))
+ msg += "
"
+ msg += _(
+ "You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system."
+ )
+ frappe.msgprint(msg)
+ elif ewb_status == "ACT" and self.invoice.ewaybill != ewb_no:
+ # if user cancelled the current eway bill and generated new eway bill using portal, then this will update new ewb number in sales invoice.
+ msg = _("E-Way Bill No. {0} doesn't match {1} saved in the invoice.").format(
+ bold(ewb_no), bold(self.invoice.ewaybill)
+ )
+ msg += "
{{ supplier_salutation if supplier_salutation else ''}} {{ supplier_name }},
{{ message }}
-{{_("The Request for Quotation can be accessed by clicking on the following button")}}:
-- -
{{_("Regards")}},
-{{ user_fullname }}
{{_("Please click on the following button to set your new password")}}:
-- -
- + + {{_("Set Password") }} + +
+ {{_("Regards")}},
+ {{ user_fullname }}
+