diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 69749c93af..f0f83b061b 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/cypress.json b/cypress.json
deleted file mode 100644
index 02b10d893f..0000000000
--- 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 da18d9352a..0000000000
--- 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 428ec5100b..0000000000
--- 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 3d6ed5d0d8..0000000000
--- 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 fcb7533a22..0000000000
--- 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 464cce48d0..0000000000
--- 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 971ac6d3ef..0000000000
--- 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 07d9804a73..0000000000
--- 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 7ddc80ab8d..0000000000
--- 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 72070cc81c..0000000000
--- 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 d90ebf6856..0000000000
--- 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/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
index 572410fc66..98f3420d87 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/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
index 3e7aa1e368..183e279fe5 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/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json
index 89a9448716..6c18a4650b 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 c52b59e4c0..7b18cdbedc 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/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 8e362de582..69a421c736 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1866,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)
@@ -1874,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]
@@ -2657,7 +2657,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.update_ordered_qty()
parent.update_ordered_and_reserved_qty()
parent.update_receiving_percentage()
- else:
+ else: # Sales Order
+ parent.validate_warehouse()
parent.update_reserved_qty()
parent.update_project()
parent.update_prevdoc_status("submit")
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py
index 02ec3bf1f3..f6fea72f8a 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/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py
index 762d0f7567..c85ec6551a 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_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py
index 1829bc4f2f..f09d7ff75a 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/hr_settings/hr_settings.py b/erpnext/hr/doctype/hr_settings/hr_settings.py
index 72a49e285a..b56f3dbe0d 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/utils.py b/erpnext/hr/utils.py
index 269e4aae31..c730b19924 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -439,20 +439,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.py b/erpnext/loan_management/doctype/loan/loan.py
index a0ef1b971c..3b76ba4edb 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -68,6 +68,8 @@ class Loan(AccountsController):
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()
@@ -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]
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index 3d96f9c9c7..d74379881c 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -93,6 +93,11 @@ frappe.ui.form.on("BOM", {
});
}
+ 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");
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 220ce1dbd8..6376359a70 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -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"""
@@ -251,9 +255,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(
{
@@ -555,35 +558,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):
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index e6d4e4446b..ae80ea172f 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import cstr, flt
from erpnext.controllers.tests.test_subcontracting_controller import set_backflush_based_on
-from erpnext.manufacturing.doctype.bom.bom import item_query, make_variant_bom
+from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
@@ -340,43 +340,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/report/quality_inspection_summary/quality_inspection_summary.py b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
index 0a79130f1b..de96a6c032 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 05ca2a8452..9829a96e09 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 8c0ebe7a90..8594ebbe9d 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
@@ -372,3 +371,5 @@ 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
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 59b17eea9f..0000000000
--- 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 33fb8f963c..0235a621ce 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/set_payroll_entry_status.py b/erpnext/patches/v13_0/set_payroll_entry_status.py
new file mode 100644
index 0000000000..97adff9295
--- /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/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 c0ef2eee78..3d1d96598f 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 1d90e7383f..2d8df35011 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 c52efaba59..b3b66b9e7b 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 58b2c1af05..416cf316c9 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 1fd1cecaaa..c540baf7e6 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 0000000000..20e3d5b4e5
--- /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 aa03d80d63..1155a06edd 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 62e183e59c..b06f3502e2 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 0444134aa4..17882eb5d9 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 473fb0d7c7..620fcadceb 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
@@ -40,8 +41,10 @@ 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):
@@ -51,6 +54,15 @@ class PayrollEntry(Document):
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:
@@ -87,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):
"""
@@ -183,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
@@ -214,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"):
@@ -233,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
@@ -790,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)
+ )
)
@@ -837,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 0000000000..56390b79d8
--- /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 fda0fcf8be..0363a0c3de 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 6a7f72b013..4c5fea1e75 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()
@@ -1364,9 +1367,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 +1378,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:
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 1bc3741922..5e3814b73c 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -997,7 +997,7 @@ class TestSalarySlip(unittest.TestCase):
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 +1008,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 +1022,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 +1050,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:
diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.py b/erpnext/payroll/doctype/salary_structure/salary_structure.py
index fa36b7ab2d..edf17dbfb1 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 e9b5ed2261..8cc2ea3314 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/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 05a401bdee..de93c82ef2 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/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js
index d378118564..a6bff2c148 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/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
index 4748b265dc..580e6469e2 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]) {
@@ -150,52 +150,72 @@ erpnext.setup_einvoice_actions = (doctype) => {
if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) {
const action = () => {
- let message = __('Cancellation of e-way bill is currently not supported.') + ' ';
- message += '
';
- message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.');
-
- const dialog = frappe.msgprint({
- title: __('Update E-Way Bill Cancelled Status?'),
- message: message,
- indicator: 'orange',
- primary_action: {
- action: function() {
- frappe.call({
- method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill',
- args: { doctype, docname: name },
- freeze: true,
- callback: () => frm.reload_doc() && dialog.hide()
- });
- }
+ // 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 },
+ freeze: true,
+ callback: () => frm.reload_doc(),
+ });
},
- primary_action_label: __('Yes')
- });
+ () => {
+ 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 bcb3e4fb85..9add09beaf 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -803,6 +803,8 @@ class GSPConnector:
self.gstin_details_url = self.base_url + "/enriched/ei/api/master/gstin"
# 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"
@@ -1010,13 +1012,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
@@ -1186,23 +1207,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"]
- del 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
@@ -1211,9 +1231,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 += "