Merge branch 'frappe:develop' into develop

This commit is contained in:
Vladislav
2022-06-10 13:03:07 +03:00
committed by GitHub
142 changed files with 3075 additions and 2507 deletions

View File

@@ -11,7 +11,7 @@ fi
cd ~ || exit cd ~ || exit
sudo apt-get install redis-server libcups2-dev sudo apt update && sudo apt install redis-server libcups2-dev
pip install frappe-bench pip install frappe-bench

12
.github/stale.yml vendored
View File

@@ -24,14 +24,4 @@ pulls:
:) Also, even if it is closed, you can always reopen the PR when you're :) Also, even if it is closed, you can always reopen the PR when you're
ready. Thank you for contributing. ready. Thank you for contributing.
issues: only: pulls
daysUntilStale: 90
daysUntilClose: 7
exemptLabels:
- valid
- to-validate
- QA
markComment: >
This issue has been automatically marked as inactive because it has not had
recent activity and it wasn't validated by maintainer team. It will be
closed within a week if no further activity occurs.

View File

@@ -1,11 +0,0 @@
{
"baseUrl": "http://test_site:8000/",
"projectId": "da59y9",
"adminPassword": "admin",
"defaultCommandTimeout": 20000,
"pageLoadTimeout": 15000,
"retries": {
"runMode": 2,
"openMode": 2
}
}

View File

@@ -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"
}

View File

@@ -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");
});
});

View File

@@ -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');
});
});

View File

@@ -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");
});
});

View File

@@ -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]}`);
});
});
});

View File

@@ -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]}`);
});
});
});

View File

@@ -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
};

View File

@@ -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)}`);
});

View File

@@ -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'
});

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"baseUrl": "../node_modules",
"types": [
"cypress"
]
},
"include": [
"**/*.*"
]
}

View File

@@ -54,8 +54,8 @@ class PeriodClosingVoucher(AccountsController):
pce = frappe.db.sql( pce = frappe.db.sql(
"""select name from `tabPeriod Closing Voucher` """select name from `tabPeriod Closing Voucher`
where posting_date > %s and fiscal_year = %s and docstatus = 1""", where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""",
(self.posting_date, self.fiscal_year), (self.posting_date, self.fiscal_year, self.company),
) )
if pce and pce[0][0]: if pce and pce[0][0]:
frappe.throw( frappe.throw(

View File

@@ -45,8 +45,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
if (this.frm.doc.supplier && this.frm.doc.__islocal) { if (this.frm.doc.supplier && this.frm.doc.__islocal) {
this.frm.trigger('supplier'); this.frm.trigger('supplier');
} }
erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
} }
refresh(doc) { refresh(doc) {

View File

@@ -545,7 +545,16 @@ class PurchaseInvoice(BuyingController):
from_repost=from_repost, from_repost=from_repost,
) )
elif self.docstatus == 2: elif self.docstatus == 2:
provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"]
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
if provisional_entries:
for entry in provisional_entries:
frappe.db.set_value(
"GL Entry",
{"voucher_type": "Purchase Receipt", "voucher_detail_no": entry.voucher_detail_no},
"is_cancelled",
1,
)
if update_outstanding == "No": if update_outstanding == "No":
update_outstanding_amt( update_outstanding_amt(
@@ -1127,7 +1136,7 @@ class PurchaseInvoice(BuyingController):
# Stock ledger value is not matching with the warehouse amount # Stock ledger value is not matching with the warehouse amount
if ( if (
self.update_stock self.update_stock
and voucher_wise_stock_value.get(item.name) and voucher_wise_stock_value.get((item.name, item.warehouse))
and warehouse_debit_amount and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
): ):

View File

@@ -27,12 +27,13 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt, make_purchase_receipt,
) )
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
from erpnext.stock.tests.test_utils import StockTestMixin
test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"] test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"]
test_ignore = ["Serial No"] test_ignore = ["Serial No"]
class TestPurchaseInvoice(unittest.TestCase): class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
@classmethod @classmethod
def setUpClass(self): def setUpClass(self):
unlink_payment_on_cancel_of_invoice() unlink_payment_on_cancel_of_invoice()
@@ -693,6 +694,80 @@ class TestPurchaseInvoice(unittest.TestCase):
self.assertEqual(expected_values[gle.account][0], gle.debit) self.assertEqual(expected_values[gle.account][0], gle.debit)
self.assertEqual(expected_values[gle.account][1], gle.credit) self.assertEqual(expected_values[gle.account][1], gle.credit)
def test_standalone_return_using_pi(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item = self.make_item().name
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
make_stock_entry(item_code=item, target=warehouse, qty=50, rate=120)
return_pi = make_purchase_invoice(
is_return=1,
item=item,
qty=-10,
update_stock=1,
rate=100,
company=company,
warehouse=warehouse,
cost_center="Main - TCP1",
)
# assert that stock consumption is with actual rate
self.assertGLEs(
return_pi,
[{"credit": 1200, "debit": 0}],
gle_filters={"account": "Stock In Hand - TCP1"},
)
# assert loss booked in COGS
self.assertGLEs(
return_pi,
[{"credit": 0, "debit": 200}],
gle_filters={"account": "Cost of Goods Sold - TCP1"},
)
def test_return_with_lcv(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
)
item = self.make_item().name
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
cost_center = "Main - TCP1"
pi = make_purchase_invoice(
item=item,
company=company,
warehouse=warehouse,
cost_center=cost_center,
update_stock=1,
qty=10,
rate=100,
)
# Create landed cost voucher - will increase valuation of received item by 10
create_landed_cost_voucher("Purchase Invoice", pi.name, pi.company, charges=100)
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save().submit()
# assert that stock consumption is with actual in rate
self.assertGLEs(
return_pi,
[{"credit": 1100, "debit": 0}],
gle_filters={"account": "Stock In Hand - TCP1"},
)
# assert loss booked in COGS
self.assertGLEs(
return_pi,
[{"credit": 0, "debit": 100}],
gle_filters={"account": "Cost of Goods Sold - TCP1"},
)
def test_multi_currency_gle(self): def test_multi_currency_gle(self):
pi = make_purchase_invoice( pi = make_purchase_invoice(
supplier="_Test Supplier USD", supplier="_Test Supplier USD",
@@ -1526,6 +1601,18 @@ class TestPurchaseInvoice(unittest.TestCase):
check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date) check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date)
# Cancel purchase invoice to check reverse provisional entry cancellation
pi.cancel()
expected_gle_for_purchase_receipt_post_pi_cancel = [
["Provision Account - _TC", 0, 250, pi.posting_date],
["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date],
]
check_gl_entries(
self, pr.name, expected_gle_for_purchase_receipt_post_pi_cancel, pr.posting_date
)
company.enable_provisional_accounting_for_non_stock_items = 0 company.enable_provisional_accounting_for_non_stock_items = 0
company.save() company.save()

View File

@@ -52,7 +52,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
me.frm.refresh_fields(); me.frm.refresh_fields();
} }
erpnext.queries.setup_warehouse_query(this.frm); erpnext.queries.setup_warehouse_query(this.frm);
erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
} }
refresh(doc, dt, dn) { refresh(doc, dt, dn) {

View File

@@ -1790,6 +1790,8 @@
"width": "50%" "width": "50%"
}, },
{ {
"fetch_from": "sales_partner.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate", "fieldname": "commission_rate",
"fieldtype": "Float", "fieldtype": "Float",
"hide_days": 1, "hide_days": 1,
@@ -2038,7 +2040,7 @@
"link_fieldname": "consolidated_invoice" "link_fieldname": "consolidated_invoice"
} }
], ],
"modified": "2022-03-08 16:08:53.517903", "modified": "2022-06-10 03:52:51.409913",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice", "name": "Sales Invoice",

View File

@@ -13,7 +13,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts", "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"modified": "2022-01-18 18:35:52.326688", "modified": "2022-06-07 14:29:21.352132",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts", "name": "Accounts",

View File

@@ -1,8 +1,8 @@
{ {
"action": "Watch Video", "action": "Go to Page",
"action_label": "Learn more about Chart of Accounts", "action_label": "Learn more about Chart of Accounts",
"callback_message": "You can continue with the onboarding after exploring this page", "callback_message": "You can continue with the onboarding after exploring this page",
"callback_title": "Awesome Work", "callback_title": "Explore Chart of Accounts",
"creation": "2020-05-13 19:58:20.928127", "creation": "2020-05-13 19:58:20.928127",
"description": "# Chart Of Accounts\n\nERPNext sets up a simple chart of accounts for each Company you create, but you can modify it according to business and legal requirements.", "description": "# Chart Of Accounts\n\nERPNext sets up a simple chart of accounts for each Company you create, but you can modify it according to business and legal requirements.",
"docstatus": 0, "docstatus": 0,
@@ -12,7 +12,7 @@
"is_complete": 0, "is_complete": 0,
"is_single": 0, "is_single": 0,
"is_skipped": 0, "is_skipped": 0,
"modified": "2021-08-13 11:46:25.878506", "modified": "2022-06-07 14:21:26.264769",
"modified_by": "Administrator", "modified_by": "Administrator",
"name": "Chart of Accounts", "name": "Chart of Accounts",
"owner": "Administrator", "owner": "Administrator",

View File

@@ -2,14 +2,14 @@
"action": "Create Entry", "action": "Create Entry",
"action_label": "Manage Sales Tax Templates", "action_label": "Manage Sales Tax Templates",
"creation": "2020-05-13 19:29:43.844463", "creation": "2020-05-13 19:29:43.844463",
"description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n", "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n\n[Checkout pre-configured taxes](/app/sales-taxes-and-charges-template)\n",
"docstatus": 0, "docstatus": 0,
"doctype": "Onboarding Step", "doctype": "Onboarding Step",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"is_single": 0, "is_single": 0,
"is_skipped": 0, "is_skipped": 0,
"modified": "2021-08-13 11:48:37.238610", "modified": "2022-06-07 14:27:15.906286",
"modified_by": "Administrator", "modified_by": "Administrator",
"name": "Setup Taxes", "name": "Setup Taxes",
"owner": "Administrator", "owner": "Administrator",

View File

@@ -42,7 +42,7 @@
{% if(filters.show_future_payments) { %} {% if(filters.show_future_payments) { %}
{% var balance_row = data.slice(-1).pop(); {% var balance_row = data.slice(-1).pop();
var start = filters.based_on_payment_terms ? 13 : 11; var start = report.columns.findIndex((elem) => (elem.fieldname == 'age'));
var range1 = report.columns[start].label; var range1 = report.columns[start].label;
var range2 = report.columns[start+1].label; var range2 = report.columns[start+1].label;
var range3 = report.columns[start+2].label; var range3 = report.columns[start+2].label;

View File

@@ -50,7 +50,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
"fieldtype": "Link", "fieldtype": "Link",
"options": "Fiscal Year", "options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"), "default": frappe.defaults.get_user_default("fiscal_year"),
"reqd": 1 "reqd": 1,
on_change: () => {
frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) {
let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date");
frappe.query_report.set_filter_value({
period_start_date: year_start_date
});
});
}
}, },
{ {
"fieldname":"to_fiscal_year", "fieldname":"to_fiscal_year",
@@ -58,7 +66,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
"fieldtype": "Link", "fieldtype": "Link",
"options": "Fiscal Year", "options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"), "default": frappe.defaults.get_user_default("fiscal_year"),
"reqd": 1 "reqd": 1,
on_change: () => {
frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) {
let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date");
frappe.query_report.set_filter_value({
period_end_date: year_end_date
});
});
}
}, },
{ {
"fieldname":"finance_book", "fieldname":"finance_book",

View File

@@ -35,7 +35,7 @@ frappe.query_reports["Gross Profit"] = {
"fieldname":"group_by", "fieldname":"group_by",
"label": __("Group By"), "label": __("Group By"),
"fieldtype": "Select", "fieldtype": "Select",
"options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject", "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject\nMonthly\nPayment Term",
"default": "Invoice" "default": "Invoice"
}, },
], ],

View File

@@ -4,7 +4,7 @@
import frappe import frappe
from frappe import _, scrub from frappe import _, scrub
from frappe.utils import cint, flt from frappe.utils import cint, flt, formatdate
from erpnext.controllers.queries import get_match_cond from erpnext.controllers.queries import get_match_cond
from erpnext.stock.utils import get_incoming_rate from erpnext.stock.utils import get_incoming_rate
@@ -124,6 +124,23 @@ def execute(filters=None):
"gross_profit", "gross_profit",
"gross_profit_percent", "gross_profit_percent",
], ],
"monthly": [
"monthly",
"qty",
"base_rate",
"buying_rate",
"base_amount",
"buying_amount",
"gross_profit",
"gross_profit_percent",
],
"payment_term": [
"payment_term",
"base_amount",
"buying_amount",
"gross_profit",
"gross_profit_percent",
],
} }
) )
@@ -317,6 +334,19 @@ def get_columns(group_wise_columns, filters):
"options": "territory", "options": "territory",
"width": 100, "width": 100,
}, },
"monthly": {
"label": _("Monthly"),
"fieldname": "monthly",
"fieldtype": "Data",
"width": 100,
},
"payment_term": {
"label": _("Payment Term"),
"fieldname": "payment_term",
"fieldtype": "Link",
"options": "Payment Term",
"width": 170,
},
} }
) )
@@ -390,6 +420,9 @@ class GrossProfitGenerator(object):
buying_amount = 0 buying_amount = 0
for row in reversed(self.si_list): for row in reversed(self.si_list):
if self.filters.get("group_by") == "Monthly":
row.monthly = formatdate(row.posting_date, "MMM YYYY")
if self.skip_row(row): if self.skip_row(row):
continue continue
@@ -445,17 +478,7 @@ class GrossProfitGenerator(object):
def get_average_rate_based_on_group_by(self): def get_average_rate_based_on_group_by(self):
for key in list(self.grouped): for key in list(self.grouped):
if self.filters.get("group_by") != "Invoice": if self.filters.get("group_by") == "Invoice":
for i, row in enumerate(self.grouped[key]):
if i == 0:
new_row = row
else:
new_row.qty += flt(row.qty)
new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
new_row.base_amount += flt(row.base_amount, self.currency_precision)
new_row = self.set_average_rate(new_row)
self.grouped_data.append(new_row)
else:
for i, row in enumerate(self.grouped[key]): for i, row in enumerate(self.grouped[key]):
if row.indent == 1.0: if row.indent == 1.0:
if ( if (
@@ -469,6 +492,44 @@ class GrossProfitGenerator(object):
if flt(row.qty) or row.base_amount: if flt(row.qty) or row.base_amount:
row = self.set_average_rate(row) row = self.set_average_rate(row)
self.grouped_data.append(row) self.grouped_data.append(row)
elif self.filters.get("group_by") == "Payment Term":
for i, row in enumerate(self.grouped[key]):
invoice_portion = 0
if row.is_return:
invoice_portion = 100
elif row.invoice_portion:
invoice_portion = row.invoice_portion
else:
invoice_portion = row.payment_amount * 100 / row.base_net_amount
if i == 0:
new_row = row
self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion)
else:
new_row.qty += flt(row.qty)
self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True)
new_row = self.set_average_rate(new_row)
self.grouped_data.append(new_row)
else:
for i, row in enumerate(self.grouped[key]):
if i == 0:
new_row = row
else:
new_row.qty += flt(row.qty)
new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
new_row.base_amount += flt(row.base_amount, self.currency_precision)
new_row = self.set_average_rate(new_row)
self.grouped_data.append(new_row)
def set_average_based_on_payment_term_portion(self, new_row, row, invoice_portion, aggr=False):
cols = ["base_amount", "buying_amount", "gross_profit"]
for col in cols:
if aggr:
new_row[col] += row[col] * invoice_portion / 100
else:
new_row[col] = row[col] * invoice_portion / 100
def is_not_invoice_row(self, row): def is_not_invoice_row(self, row):
return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get( return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get(
@@ -622,6 +683,20 @@ class GrossProfitGenerator(object):
sales_person_cols = "" sales_person_cols = ""
sales_team_table = "" sales_team_table = ""
if self.filters.group_by == "Payment Term":
payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
'{0}',
coalesce(schedule.payment_term, '{1}')) as payment_term,
schedule.invoice_portion,
schedule.payment_amount """.format(
_("Sales Return"), _("No Terms")
)
payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
`tabSales Invoice`.is_return = 0 """
else:
payment_term_cols = ""
payment_term_table = ""
if self.filters.get("sales_invoice"): if self.filters.get("sales_invoice"):
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s" conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
@@ -644,10 +719,12 @@ class GrossProfitGenerator(object):
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
`tabSales Invoice Item`.cost_center `tabSales Invoice Item`.cost_center
{sales_person_cols} {sales_person_cols}
{payment_term_cols}
from from
`tabSales Invoice` inner join `tabSales Invoice Item` `tabSales Invoice` inner join `tabSales Invoice Item`
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
{sales_team_table} {sales_team_table}
{payment_term_table}
where where
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond} `tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
order by order by
@@ -655,6 +732,8 @@ class GrossProfitGenerator(object):
conditions=conditions, conditions=conditions,
sales_person_cols=sales_person_cols, sales_person_cols=sales_person_cols,
sales_team_table=sales_team_table, sales_team_table=sales_team_table,
payment_term_cols=payment_term_cols,
payment_term_table=payment_term_table,
match_cond=get_match_cond("Sales Invoice"), match_cond=get_match_cond("Sales Invoice"),
), ),
self.filters, self.filters,

View File

@@ -443,12 +443,6 @@ def get_grand_total(filters, doctype):
] # nosec ] # nosec
def get_deducted_taxes():
return frappe.db.sql_list(
"select name from `tabPurchase Taxes and Charges` where add_deduct_tax = 'Deduct'"
)
def get_tax_accounts( def get_tax_accounts(
item_list, item_list,
columns, columns,
@@ -462,6 +456,7 @@ def get_tax_accounts(
tax_columns = [] tax_columns = []
invoice_item_row = {} invoice_item_row = {}
itemised_tax = {} itemised_tax = {}
add_deduct_tax = "charge_type"
tax_amount_precision = ( tax_amount_precision = (
get_field_precision( get_field_precision(
@@ -477,13 +472,13 @@ def get_tax_accounts(
conditions = "" conditions = ""
if doctype == "Purchase Invoice": if doctype == "Purchase Invoice":
conditions = " and category in ('Total', 'Valuation and Total') and base_tax_amount_after_discount_amount != 0" conditions = " and category in ('Total', 'Valuation and Total') and base_tax_amount_after_discount_amount != 0"
add_deduct_tax = "add_deduct_tax"
deducted_tax = get_deducted_taxes()
tax_details = frappe.db.sql( tax_details = frappe.db.sql(
""" """
select select
name, parent, description, item_wise_tax_detail, name, parent, description, item_wise_tax_detail,
charge_type, base_tax_amount_after_discount_amount charge_type, {add_deduct_tax}, base_tax_amount_after_discount_amount
from `tab%s` from `tab%s`
where where
parenttype = %s and docstatus = 1 parenttype = %s and docstatus = 1
@@ -491,12 +486,22 @@ def get_tax_accounts(
and parent in (%s) and parent in (%s)
%s %s
order by description order by description
""" """.format(
add_deduct_tax=add_deduct_tax
)
% (tax_doctype, "%s", ", ".join(["%s"] * len(invoice_item_row)), conditions), % (tax_doctype, "%s", ", ".join(["%s"] * len(invoice_item_row)), conditions),
tuple([doctype] + list(invoice_item_row)), tuple([doctype] + list(invoice_item_row)),
) )
for name, parent, description, item_wise_tax_detail, charge_type, tax_amount in tax_details: for (
name,
parent,
description,
item_wise_tax_detail,
charge_type,
add_deduct_tax,
tax_amount,
) in tax_details:
description = handle_html(description) description = handle_html(description)
if description not in tax_columns and tax_amount: if description not in tax_columns and tax_amount:
# as description is text editor earlier and markup can break the column convention in reports # as description is text editor earlier and markup can break the column convention in reports
@@ -529,7 +534,9 @@ def get_tax_accounts(
if item_tax_amount: if item_tax_amount:
tax_value = flt(item_tax_amount, tax_amount_precision) tax_value = flt(item_tax_amount, tax_amount_precision)
tax_value = ( tax_value = (
tax_value * -1 if (doctype == "Purchase Invoice" and name in deducted_tax) else tax_value tax_value * -1
if (doctype == "Purchase Invoice" and add_deduct_tax == "Deduct")
else tax_value
) )
itemised_tax.setdefault(d.name, {})[description] = frappe._dict( itemised_tax.setdefault(d.name, {})[description] = frappe._dict(

View File

@@ -211,6 +211,7 @@ def set_gl_entries_by_account(
{additional_conditions} {additional_conditions}
and posting_date <= %(to_date)s and posting_date <= %(to_date)s
and {based_on} is not null and {based_on} is not null
and is_cancelled = 0
order by {based_on}, posting_date""".format( order by {based_on}, posting_date""".format(
additional_conditions="\n".join(additional_conditions), based_on=based_on additional_conditions="\n".join(additional_conditions), based_on=based_on
), ),

View File

@@ -346,9 +346,13 @@ def get_columns(invoice_list, additional_table_columns):
def get_conditions(filters): def get_conditions(filters):
conditions = "" conditions = ""
accounting_dimensions = get_accounting_dimensions(as_list=False) or []
accounting_dimensions_list = [d.fieldname for d in accounting_dimensions]
if filters.get("company"): if filters.get("company"):
conditions += " and company=%(company)s" conditions += " and company=%(company)s"
if filters.get("customer"):
if filters.get("customer") and "customer" not in accounting_dimensions_list:
conditions += " and customer = %(customer)s" conditions += " and customer = %(customer)s"
if filters.get("from_date"): if filters.get("from_date"):
@@ -359,32 +363,18 @@ def get_conditions(filters):
if filters.get("owner"): if filters.get("owner"):
conditions += " and owner = %(owner)s" conditions += " and owner = %(owner)s"
if filters.get("mode_of_payment"): def get_sales_invoice_item_field_condition(field, table="Sales Invoice Item") -> str:
conditions += """ and exists(select name from `tabSales Invoice Payment` if not filters.get(field) or field in accounting_dimensions_list:
where parent=`tabSales Invoice`.name return ""
and ifnull(`tabSales Invoice Payment`.mode_of_payment, '') = %(mode_of_payment)s)""" return f""" and exists(select name from `tab{table}`
where parent=`tabSales Invoice`.name
and ifnull(`tab{table}`.{field}, '') = %({field})s)"""
if filters.get("cost_center"): conditions += get_sales_invoice_item_field_condition("mode_of_payments", "Sales Invoice Payment")
conditions += """ and exists(select name from `tabSales Invoice Item` conditions += get_sales_invoice_item_field_condition("cost_center")
where parent=`tabSales Invoice`.name conditions += get_sales_invoice_item_field_condition("warehouse")
and ifnull(`tabSales Invoice Item`.cost_center, '') = %(cost_center)s)""" conditions += get_sales_invoice_item_field_condition("brand")
conditions += get_sales_invoice_item_field_condition("item_group")
if filters.get("warehouse"):
conditions += """ and exists(select name from `tabSales Invoice Item`
where parent=`tabSales Invoice`.name
and ifnull(`tabSales Invoice Item`.warehouse, '') = %(warehouse)s)"""
if filters.get("brand"):
conditions += """ and exists(select name from `tabSales Invoice Item`
where parent=`tabSales Invoice`.name
and ifnull(`tabSales Invoice Item`.brand, '') = %(brand)s)"""
if filters.get("item_group"):
conditions += """ and exists(select name from `tabSales Invoice Item`
where parent=`tabSales Invoice`.name
and ifnull(`tabSales Invoice Item`.item_group, '') = %(item_group)s)"""
accounting_dimensions = get_accounting_dimensions(as_list=False)
if accounting_dimensions: if accounting_dimensions:
common_condition = """ common_condition = """

View File

@@ -160,14 +160,12 @@ def get_rootwise_opening_balances(filters, report_type):
if filters.project: if filters.project:
additional_conditions += " and project = %(project)s" additional_conditions += " and project = %(project)s"
if filters.finance_book: if filters.get("include_default_book_entries"):
fb_conditions = " AND finance_book = %(finance_book)s" additional_conditions += (
if filters.include_default_book_entries: " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)"
fb_conditions = ( )
" AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" else:
) additional_conditions += " AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)"
additional_conditions += fb_conditions
accounting_dimensions = get_accounting_dimensions(as_list=False) accounting_dimensions = get_accounting_dimensions(as_list=False)

View File

@@ -28,6 +28,7 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [
("Item-wise Sales Register", {}), ("Item-wise Sales Register", {}),
("Item-wise Purchase Register", {}), ("Item-wise Purchase Register", {}),
("Sales Register", {}), ("Sales Register", {}),
("Sales Register", {"item_group": "All Item Groups"}),
("Purchase Register", {}), ("Purchase Register", {}),
( (
"Tax Detail", "Tax Detail",

View File

@@ -1124,6 +1124,9 @@ def update_gl_entries_after(
def repost_gle_for_stock_vouchers( def repost_gle_for_stock_vouchers(
stock_vouchers, posting_date, company=None, warehouse_account=None stock_vouchers, posting_date, company=None, warehouse_account=None
): ):
from erpnext.accounts.general_ledger import toggle_debit_credit_if_negative
if not stock_vouchers: if not stock_vouchers:
return return
@@ -1142,10 +1145,12 @@ def repost_gle_for_stock_vouchers(
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2
gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) gle = get_voucherwise_gl_entries(stock_vouchers, posting_date)
for voucher_type, voucher_no in stock_vouchers: for idx, (voucher_type, voucher_no) in enumerate(stock_vouchers):
existing_gle = gle.get((voucher_type, voucher_no), []) existing_gle = gle.get((voucher_type, voucher_no), [])
voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no) voucher_obj = frappe.get_doc(voucher_type, voucher_no)
expected_gle = voucher_obj.get_gl_entries(warehouse_account) # Some transactions post credit as negative debit, this is handled while posting GLE
# but while comparing we need to make sure it's flipped so comparisons are accurate
expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account))
if expected_gle: if expected_gle:
if not existing_gle or not compare_existing_and_expected_gle( if not existing_gle or not compare_existing_and_expected_gle(
existing_gle, expected_gle, precision existing_gle, expected_gle, precision
@@ -1155,6 +1160,11 @@ def repost_gle_for_stock_vouchers(
else: else:
_delete_gl_entries(voucher_type, voucher_no) _delete_gl_entries(voucher_type, voucher_no)
if idx % 20 == 0:
# Commit every 20 documents to avoid losing progress
# and reducing memory usage
frappe.db.commit()
def sort_stock_vouchers_by_posting_date( def sort_stock_vouchers_by_posting_date(
stock_vouchers: List[Tuple[str, str]] stock_vouchers: List[Tuple[str, str]]

View File

@@ -148,7 +148,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2022-04-14 15:56:42.340223", "modified": "2022-05-31 19:40:26.103909",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Buying Settings", "name": "Buying Settings",
@@ -162,6 +162,16 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"role": "Purchase Manager",
"share": 1,
"write": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@@ -18,7 +18,7 @@ class BuyingSettings(Document):
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]: for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
frappe.db.set_default(key, self.get(key, "")) 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( set_by_naming_series(
"Supplier", "Supplier",

View File

@@ -43,8 +43,6 @@ frappe.ui.form.on("Purchase Order", {
erpnext.queries.setup_queries(frm, "Warehouse", function() { erpnext.queries.setup_queries(frm, "Warehouse", function() {
return erpnext.queries.warehouse(frm.doc); return erpnext.queries.warehouse(frm.doc);
}); });
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
}, },
apply_tds: function(frm) { apply_tds: function(frm) {

View File

@@ -84,6 +84,9 @@ class Supplier(TransactionBase):
self.save() self.save()
def validate_internal_supplier(self): def validate_internal_supplier(self):
if not self.is_internal_supplier:
self.represents_company = ""
internal_supplier = frappe.db.get_value( internal_supplier = frappe.db.get_value(
"Supplier", "Supplier",
{ {

View File

@@ -1465,8 +1465,8 @@ class AccountsController(TransactionBase):
if not party_gle_currency and (party_account_currency != self.currency): if not party_gle_currency and (party_account_currency != self.currency):
frappe.throw( frappe.throw(
_("Party Account {0} currency and document currency should be same").format( _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format(
frappe.bold(party_account) frappe.bold(party_account), party_account_currency, self.currency
) )
) )
@@ -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): def get_taxes_and_charges(master_doctype, master_name):
if not master_name: if not master_name:
return 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) 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")): for i, tax in enumerate(tax_master.get("taxes")):
tax = tax.as_dict() tax = tax.as_dict()
for fieldname in default_fields: for fieldname in default_fields + child_table_fields:
if fieldname in tax: if fieldname in tax:
del tax[fieldname] del tax[fieldname]
@@ -2661,7 +2661,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.update_reserved_qty_for_subcontract() parent.update_reserved_qty_for_subcontract()
parent.create_raw_materials_supplied("supplied_items") parent.create_raw_materials_supplied("supplied_items")
parent.save() parent.save()
else: else: # Sales Order
parent.validate_warehouse()
parent.update_reserved_qty() parent.update_reserved_qty()
parent.update_project() parent.update_project()
parent.update_prevdoc_status("submit") parent.update_prevdoc_status("submit")

View File

@@ -316,7 +316,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype):
return data[0] return data[0]
def make_return_doc(doctype, source_name, target_doc=None): def make_return_doc(doctype: str, source_name: str, target_doc=None):
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos

View File

@@ -34,9 +34,7 @@ class WebsiteItem(WebsiteGenerator):
def autoname(self): def autoname(self):
# use naming series to accomodate items with same name (different item code) # use naming series to accomodate items with same name (different item code)
from frappe.model.naming import make_autoname from frappe.model.naming import get_default_naming_series, make_autoname
from erpnext.setup.doctype.naming_series.naming_series import get_default_naming_series
naming_series = get_default_naming_series("Website Item") naming_series = get_default_naming_series("Website Item")
if not self.name and naming_series: if not self.name and naming_series:

View File

@@ -38,7 +38,7 @@ def is_search_module_loaded():
out = cache.execute_command("MODULE LIST") out = cache.execute_command("MODULE LIST")
parsed_output = " ".join( parsed_output = " ".join(
(" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out) (" ".join([frappe.as_unicode(s) for s in o if not isinstance(s, int)]) for o in out)
) )
return "search" in parsed_output return "search" in parsed_output
except Exception: except Exception:

View File

@@ -1,44 +0,0 @@
import frappe
from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection
from github import Github
class GithubConnection(BaseConnection):
def __init__(self, connector):
self.connector = connector
try:
password = self.get_password()
except frappe.AuthenticationError:
password = None
if self.connector.username and password:
self.connection = Github(self.connector.username, self.get_password())
else:
self.connection = Github()
self.name_field = 'id'
def insert(self, doctype, doc):
pass
def update(self, doctype, doc, migration_id):
pass
def delete(self, doctype, migration_id):
pass
def get(self, remote_objectname, fields=None, filters=None, start=0, page_length=10):
repo = filters.get('repo')
if remote_objectname == 'Milestone':
return self.get_milestones(repo, start, page_length)
if remote_objectname == 'Issue':
return self.get_issues(repo, start, page_length)
def get_milestones(self, repo, start=0, page_length=10):
_repo = self.connection.get_repo(repo)
return list(_repo.get_milestones()[start:start+page_length])
def get_issues(self, repo, start=0, page_length=10):
_repo = self.connection.get_repo(repo)
return list(_repo.get_issues()[start:start+page_length])

View File

@@ -1,12 +0,0 @@
import frappe
def pre_process(issue):
project = frappe.db.get_value("Project", filters={"project_name": issue.milestone})
return {
"title": issue.title,
"body": frappe.utils.md_to_html(issue.body or ""),
"state": issue.state.title(),
"project": project or "",
}

View File

@@ -1,36 +0,0 @@
{
"condition": "{\"repo\":\"frappe/erpnext\"}",
"creation": "2017-10-16 16:03:32.772191",
"docstatus": 0,
"doctype": "Data Migration Mapping",
"fields": [
{
"is_child_table": 0,
"local_fieldname": "subject",
"remote_fieldname": "title"
},
{
"is_child_table": 0,
"local_fieldname": "description",
"remote_fieldname": "body"
},
{
"is_child_table": 0,
"local_fieldname": "status",
"remote_fieldname": "state"
}
],
"idx": 0,
"local_doctype": "Task",
"local_primary_key": "name",
"mapping_name": "Issue to Task",
"mapping_type": "Pull",
"migration_id_field": "github_sync_id",
"modified": "2017-10-20 11:48:54.575993",
"modified_by": "Administrator",
"name": "Issue to Task",
"owner": "Administrator",
"page_length": 10,
"remote_objectname": "Issue",
"remote_primary_key": "id"
}

View File

@@ -1,6 +0,0 @@
def pre_process(milestone):
return {
"title": milestone.title,
"description": milestone.description,
"state": milestone.state.title(),
}

View File

@@ -1,36 +0,0 @@
{
"condition": "{\"repo\": \"frappe/erpnext\"}",
"creation": "2017-10-13 11:16:49.664925",
"docstatus": 0,
"doctype": "Data Migration Mapping",
"fields": [
{
"is_child_table": 0,
"local_fieldname": "project_name",
"remote_fieldname": "title"
},
{
"is_child_table": 0,
"local_fieldname": "notes",
"remote_fieldname": "description"
},
{
"is_child_table": 0,
"local_fieldname": "status",
"remote_fieldname": "state"
}
],
"idx": 0,
"local_doctype": "Project",
"local_primary_key": "project_name",
"mapping_name": "Milestone to Project",
"mapping_type": "Pull",
"migration_id_field": "github_sync_id",
"modified": "2017-10-20 11:48:54.552305",
"modified_by": "Administrator",
"name": "Milestone to Project",
"owner": "Administrator",
"page_length": 10,
"remote_objectname": "Milestone",
"remote_primary_key": "id"
}

View File

@@ -1,22 +0,0 @@
{
"creation": "2017-10-13 11:16:53.600026",
"docstatus": 0,
"doctype": "Data Migration Plan",
"idx": 0,
"mappings": [
{
"enabled": 1,
"mapping": "Milestone to Project"
},
{
"enabled": 1,
"mapping": "Issue to Task"
}
],
"modified": "2017-10-20 11:48:54.496123",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "GitHub Sync",
"owner": "Administrator",
"plan_name": "GitHub Sync"
}

View File

@@ -392,9 +392,12 @@ after_migrate = ["erpnext.setup.install.update_select_perm_after_install"]
scheduler_events = { scheduler_events = {
"cron": { "cron": {
"0/5 * * * *": [
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
],
"0/30 * * * *": [ "0/30 * * * *": [
"erpnext.utilities.doctype.video.video.update_youtube_data", "erpnext.utilities.doctype.video.video.update_youtube_data",
] ],
}, },
"all": [ "all": [
"erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.projects.doctype.project.project.project_status_update_reminder",

View File

@@ -3,7 +3,15 @@
import frappe import frappe
from frappe.tests.utils import FrappeTestCase 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 ( from erpnext.hr.doctype.attendance.attendance import (
DuplicateAttendanceError, DuplicateAttendanceError,
@@ -138,69 +146,70 @@ class TestAttendance(FrappeTestCase):
self.assertEqual(attendance, fetch_attendance) self.assertEqual(attendance, fetch_attendance)
def test_unmarked_days(self): def test_unmarked_days(self):
now = now_datetime() first_sunday = get_first_sunday(
previous_month = now.month - 1 self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
first_day = now.replace(day=1).replace(month=previous_month).date() )
attendance_date = add_days(first_sunday, 1)
employee = make_employee( 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) 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, attendance_date, "Present")
mark_attendance(employee, first_day, "Present") month_name = get_month_name(attendance_date)
month_name = get_month_name(first_day)
unmarked_days = get_unmarked_days(employee, month_name) unmarked_days = get_unmarked_days(employee, month_name)
unmarked_days = [getdate(date) for date in unmarked_days] unmarked_days = [getdate(date) for date in unmarked_days]
# attendance already marked for the day # attendance already marked for the day
self.assertNotIn(first_day, unmarked_days) self.assertNotIn(attendance_date, unmarked_days)
# attendance unmarked # 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 # holiday considered in unmarked days
self.assertIn(first_sunday, unmarked_days) self.assertIn(first_sunday, unmarked_days)
def test_unmarked_days_excluding_holidays(self): def test_unmarked_days_excluding_holidays(self):
now = now_datetime() first_sunday = get_first_sunday(
previous_month = now.month - 1 self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
first_day = now.replace(day=1).replace(month=previous_month).date() )
attendance_date = add_days(first_sunday, 1)
employee = make_employee( 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) 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, attendance_date, "Present")
mark_attendance(employee, first_day, "Present") month_name = get_month_name(attendance_date)
month_name = get_month_name(first_day)
unmarked_days = get_unmarked_days(employee, month_name, exclude_holidays=True) unmarked_days = get_unmarked_days(employee, month_name, exclude_holidays=True)
unmarked_days = [getdate(date) for date in unmarked_days] unmarked_days = [getdate(date) for date in unmarked_days]
# attendance already marked for the day # attendance already marked for the day
self.assertNotIn(first_day, unmarked_days) self.assertNotIn(attendance_date, unmarked_days)
# attendance unmarked # 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 # holidays not considered in unmarked days
self.assertNotIn(first_sunday, unmarked_days) self.assertNotIn(first_sunday, unmarked_days)
def test_unmarked_days_as_per_joining_and_relieving_dates(self): def test_unmarked_days_as_per_joining_and_relieving_dates(self):
now = now_datetime() first_sunday = get_first_sunday(
previous_month = now.month - 1 self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
first_day = now.replace(day=1).replace(month=previous_month).date() )
date = add_days(first_sunday, 1)
doj = add_days(first_day, 1) doj = add_days(date, 1)
relieving_date = add_days(first_day, 5) relieving_date = add_days(date, 5)
employee = make_employee( employee = make_employee(
"test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date "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) 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") 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 = get_unmarked_days(employee, month_name)
unmarked_days = [getdate(date) for date in unmarked_days] unmarked_days = [getdate(date) for date in unmarked_days]

View File

@@ -827,7 +827,7 @@
"idx": 24, "idx": 24,
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"modified": "2022-04-22 16:21:55.811983", "modified": "2022-06-10 01:29:32.952091",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "HR", "module": "HR",
"name": "Employee", "name": "Employee",
@@ -872,7 +872,6 @@
], ],
"search_fields": "employee_name", "search_fields": "employee_name",
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -230,7 +230,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
persons_name = anniversary_person persons_name = anniversary_person
# Number of years completed at the company # Number of years completed at the company
completed_years = getdate().year - anniversary_persons[0]["date_of_joining"].year 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: else:
person_names_with_years = [] person_names_with_years = []
names = [] names = []
@@ -239,7 +239,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
names.append(person_text) names.append(person_text)
# Number of years completed at the company # Number of years completed at the company
completed_years = getdate().year - person["date_of_joining"].year 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) person_names_with_years.append(person_text)
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim # 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 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): def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail( frappe.sendmail(
recipients=recipients, recipients=recipients,

View File

@@ -22,7 +22,7 @@ class HRSettings(Document):
PROCEED_WITH_FREQUENCY_CHANGE = False PROCEED_WITH_FREQUENCY_CHANGE = False
def set_naming_series(self): 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( set_by_naming_series(
"Employee", "Employee",

View File

@@ -173,7 +173,7 @@ frappe.ui.form.on("Leave Application", {
date: frm.doc.from_date, date: frm.doc.from_date,
to_date: frm.doc.to_date, to_date: frm.doc.to_date,
leave_type: frm.doc.leave_type, leave_type: frm.doc.leave_type,
consider_all_leaves_in_the_allocation_period: true consider_all_leaves_in_the_allocation_period: 1
}, },
callback: function (r) { callback: function (r) {
if (!r.exc && r.message) { if (!r.exc && r.message) {

View File

@@ -88,7 +88,7 @@ class LeaveApplication(Document):
share_doc_with_approver(self, self.leave_approver) share_doc_with_approver(self, self.leave_approver)
def on_submit(self): def on_submit(self):
if self.status == "Open": if self.status in ["Open", "Cancelled"]:
frappe.throw( frappe.throw(
_("Only Leave Applications with status 'Approved' and 'Rejected' can be submitted") _("Only Leave Applications with status 'Approved' and 'Rejected' can be submitted")
) )
@@ -757,22 +757,6 @@ def get_leave_details(employee, date):
leave_allocation = {} leave_allocation = {}
for d in allocation_records: for d in allocation_records:
allocation = allocation_records.get(d, frappe._dict()) allocation = allocation_records.get(d, frappe._dict())
total_allocated_leaves = (
frappe.db.get_value(
"Leave Allocation",
{
"from_date": ("<=", date),
"to_date": (">=", date),
"employee": employee,
"leave_type": allocation.leave_type,
"docstatus": 1,
},
"SUM(total_leaves_allocated)",
)
or 0
)
remaining_leaves = get_leave_balance_on( remaining_leaves = get_leave_balance_on(
employee, d, date, to_date=allocation.to_date, consider_all_leaves_in_the_allocation_period=True employee, d, date, to_date=allocation.to_date, consider_all_leaves_in_the_allocation_period=True
) )
@@ -782,10 +766,11 @@ def get_leave_details(employee, date):
leaves_pending = get_leaves_pending_approval_for_period( leaves_pending = get_leaves_pending_approval_for_period(
employee, d, allocation.from_date, end_date employee, d, allocation.from_date, end_date
) )
expired_leaves = allocation.total_leaves_allocated - (remaining_leaves + leaves_taken)
leave_allocation[d] = { leave_allocation[d] = {
"total_leaves": total_allocated_leaves, "total_leaves": allocation.total_leaves_allocated,
"expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken), "expired_leaves": expired_leaves if expired_leaves > 0 else 0,
"leaves_taken": leaves_taken, "leaves_taken": leaves_taken,
"leaves_pending_approval": leaves_pending, "leaves_pending_approval": leaves_pending,
"remaining_leaves": remaining_leaves, "remaining_leaves": remaining_leaves,
@@ -830,7 +815,7 @@ def get_leave_balance_on(
allocation_records = get_leave_allocation_records(employee, date, leave_type) allocation_records = get_leave_allocation_records(employee, date, leave_type)
allocation = allocation_records.get(leave_type, frappe._dict()) allocation = allocation_records.get(leave_type, frappe._dict())
end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date end_date = allocation.to_date if cint(consider_all_leaves_in_the_allocation_period) else date
cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date) cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date)
leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date) leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date)
@@ -1117,7 +1102,7 @@ def add_leaves(events, start, end, filter_conditions=None):
WHERE WHERE
from_date <= %(end)s AND to_date >= %(start)s <= to_date from_date <= %(end)s AND to_date >= %(start)s <= to_date
AND docstatus < 2 AND docstatus < 2
AND status != 'Rejected' AND status in ('Approved', 'Open')
""" """
if conditions: if conditions:
@@ -1201,24 +1186,33 @@ def get_mandatory_approval(doctype):
def get_approved_leaves_for_period(employee, leave_type, from_date, to_date): def get_approved_leaves_for_period(employee, leave_type, from_date, to_date):
query = """ LeaveApplication = frappe.qb.DocType("Leave Application")
select employee, leave_type, from_date, to_date, total_leave_days query = (
from `tabLeave Application` frappe.qb.from_(LeaveApplication)
where employee=%(employee)s .select(
and docstatus=1 LeaveApplication.employee,
and (from_date between %(from_date)s and %(to_date)s LeaveApplication.leave_type,
or to_date between %(from_date)s and %(to_date)s LeaveApplication.from_date,
or (from_date < %(from_date)s and to_date > %(to_date)s)) LeaveApplication.to_date,
""" LeaveApplication.total_leave_days,
if leave_type: )
query += "and leave_type=%(leave_type)s" .where(
(LeaveApplication.employee == employee)
leave_applications = frappe.db.sql( & (LeaveApplication.docstatus == 1)
query, & (LeaveApplication.status == "Approved")
{"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type}, & (
as_dict=1, (LeaveApplication.from_date.between(from_date, to_date))
| (LeaveApplication.to_date.between(from_date, to_date))
| ((LeaveApplication.from_date < from_date) & (LeaveApplication.to_date > to_date))
)
)
) )
if leave_type:
query = query.where(LeaveApplication.leave_type == leave_type)
leave_applications = query.run(as_dict=True)
leave_days = 0 leave_days = 0
for leave_app in leave_applications: for leave_app in leave_applications:
if leave_app.from_date >= getdate(from_date) and leave_app.to_date <= getdate(to_date): if leave_app.from_date >= getdate(from_date) and leave_app.to_date <= getdate(to_date):

View File

@@ -1,13 +1,14 @@
frappe.listview_settings['Leave Application'] = { frappe.listview_settings["Leave Application"] = {
add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"], add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"],
has_indicator_for_draft: 1, has_indicator_for_draft: 1,
get_indicator: function (doc) { get_indicator: function (doc) {
if (doc.status === "Approved") { let status_color = {
return [__("Approved"), "green", "status,=,Approved"]; "Approved": "green",
} else if (doc.status === "Rejected") { "Rejected": "red",
return [__("Rejected"), "red", "status,=,Rejected"]; "Open": "orange",
} else { "Cancelled": "red",
return [__("Open"), "red", "status,=,Open"]; "Submitted": "blue"
} };
return [__(doc.status), status_color[doc.status], "status,=," + doc.status];
} }
}; };

View File

@@ -76,7 +76,14 @@ _test_records = [
class TestLeaveApplication(unittest.TestCase): class TestLeaveApplication(unittest.TestCase):
def setUp(self): def setUp(self):
for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]: for dt in [
"Leave Application",
"Leave Allocation",
"Salary Slip",
"Leave Ledger Entry",
"Leave Period",
"Leave Policy Assignment",
]:
frappe.db.delete(dt) frappe.db.delete(dt)
frappe.set_user("Administrator") frappe.set_user("Administrator")
@@ -702,59 +709,24 @@ class TestLeaveApplication(unittest.TestCase):
self.assertEqual(details.leave_balance, 30) self.assertEqual(details.leave_balance, 30)
def test_earned_leaves_creation(self): def test_earned_leaves_creation(self):
from erpnext.hr.utils import allocate_earned_leaves
frappe.db.sql("""delete from `tabLeave Period`""")
frappe.db.sql("""delete from `tabLeave Policy Assignment`""")
frappe.db.sql("""delete from `tabLeave Allocation`""")
frappe.db.sql("""delete from `tabLeave Ledger Entry`""")
leave_period = get_leave_period() leave_period = get_leave_period()
employee = get_employee() employee = get_employee()
leave_type = "Test Earned Leave Type" leave_type = "Test Earned Leave Type"
frappe.delete_doc_if_exists("Leave Type", "Test Earned Leave Type", force=1) make_policy_assignment(employee, leave_type, leave_period)
frappe.get_doc(
dict(
leave_type_name=leave_type,
doctype="Leave Type",
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
max_leaves_allowed=6,
)
).insert()
leave_policy = frappe.get_doc( for i in range(0, 14):
{
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}],
}
).insert()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name,
}
leave_policy_assignments = create_assignment_for_multiple_employees(
[employee.name], frappe._dict(data)
)
from erpnext.hr.utils import allocate_earned_leaves
i = 0
while i < 14:
allocate_earned_leaves() allocate_earned_leaves()
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
# validate earned leaves creation without maximum leaves # validate earned leaves creation without maximum leaves
frappe.db.set_value("Leave Type", leave_type, "max_leaves_allowed", 0) frappe.db.set_value("Leave Type", leave_type, "max_leaves_allowed", 0)
i = 0
while i < 6: for i in range(0, 6):
allocate_earned_leaves() allocate_earned_leaves()
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
# test to not consider current leave in leave balance while submitting # test to not consider current leave in leave balance while submitting
@@ -970,6 +942,54 @@ class TestLeaveApplication(unittest.TestCase):
self.assertEqual(leave_allocation["leaves_pending_approval"], 1) self.assertEqual(leave_allocation["leaves_pending_approval"], 1)
self.assertEqual(leave_allocation["remaining_leaves"], 26) self.assertEqual(leave_allocation["remaining_leaves"], 26)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_get_earned_leave_details_for_dashboard(self):
from erpnext.hr.utils import allocate_earned_leaves
leave_period = get_leave_period()
employee = get_employee()
leave_type = "Test Earned Leave Type"
leave_policy_assignments = make_policy_assignment(employee, leave_type, leave_period)
allocation = frappe.db.get_value(
"Leave Allocation",
{"leave_policy_assignment": leave_policy_assignments[0]},
"name",
)
allocation = frappe.get_doc("Leave Allocation", allocation)
allocation.new_leaves_allocated = 2
allocation.save()
for i in range(0, 6):
allocate_earned_leaves()
first_sunday = get_first_sunday(self.holiday_list)
make_leave_application(
employee.name, add_days(first_sunday, 1), add_days(first_sunday, 1), leave_type
)
details = get_leave_details(employee.name, allocation.from_date)
leave_allocation = details["leave_allocation"][leave_type]
expected = {
"total_leaves": 2.0,
"expired_leaves": 0.0,
"leaves_taken": 1.0,
"leaves_pending_approval": 0.0,
"remaining_leaves": 1.0,
}
self.assertEqual(leave_allocation, expected)
details = get_leave_details(employee.name, getdate())
leave_allocation = details["leave_allocation"][leave_type]
expected = {
"total_leaves": 5.0,
"expired_leaves": 0.0,
"leaves_taken": 1.0,
"leaves_pending_approval": 0.0,
"remaining_leaves": 4.0,
}
self.assertEqual(leave_allocation, expected)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company") @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_get_leave_allocation_records(self): def test_get_leave_allocation_records(self):
employee = get_employee() employee = get_employee()
@@ -1100,3 +1120,36 @@ def get_first_sunday(holiday_list, for_date=None):
)[0][0] )[0][0]
return first_sunday return first_sunday
def make_policy_assignment(employee, leave_type, leave_period):
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
frappe.get_doc(
dict(
leave_type_name=leave_type,
doctype="Leave Type",
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
max_leaves_allowed=6,
)
).insert()
leave_policy = frappe.get_doc(
{
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}],
}
).insert()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name,
}
leave_policy_assignments = create_assignment_for_multiple_employees(
[employee.name], frappe._dict(data)
)
return leave_policy_assignments

View File

@@ -55,6 +55,8 @@ def update_employee_work_history(employee, details, date=None, cancel=False):
new_data = getdate(new_data) new_data = getdate(new_data)
elif fieldtype == "Datetime" and new_data: elif fieldtype == "Datetime" and new_data:
new_data = get_datetime(new_data) new_data = get_datetime(new_data)
elif fieldtype in ["Currency", "Float"] and new_data:
new_data = flt(new_data)
setattr(employee, item.fieldname, new_data) setattr(employee, item.fieldname, new_data)
if item.fieldname in ["department", "designation", "branch"]: if item.fieldname in ["department", "designation", "branch"]:
internal_work_history[item.fieldname] = item.new internal_work_history[item.fieldname] = item.new
@@ -439,20 +441,18 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining
return False return False
def get_salary_assignment(employee, date): def get_salary_assignments(employee, payroll_period):
assignment = frappe.db.sql( start_date, end_date = frappe.db.get_value(
""" "Payroll Period", payroll_period, ["start_date", "end_date"]
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,
) )
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): def get_sal_slip_total_benefit_given(employee, payroll_period, component=False):

View File

@@ -93,6 +93,12 @@ frappe.ui.form.on('Loan', {
frm.trigger("make_loan_refund"); frm.trigger("make_loan_refund");
},__('Create')); },__('Create'));
} }
if (frm.doc.status == "Loan Closure Requested" && frm.doc.is_term_loan && !frm.doc.is_secured_loan) {
frm.add_custom_button(__('Close Loan'), function() {
frm.trigger("close_unsecured_term_loan");
},__('Status'));
}
} }
frm.trigger("toggle_fields"); frm.trigger("toggle_fields");
}, },
@@ -174,6 +180,18 @@ frappe.ui.form.on('Loan', {
}) })
}, },
close_unsecured_term_loan: function(frm) {
frappe.call({
args: {
"loan": frm.doc.name
},
method: "erpnext.loan_management.doctype.loan.loan.close_unsecured_term_loan",
callback: function () {
frm.refresh();
}
})
},
request_loan_closure: function(frm) { request_loan_closure: function(frm) {
frappe.confirm(__("Do you really want to close this loan"), frappe.confirm(__("Do you really want to close this loan"),
function() { function() {

View File

@@ -60,18 +60,20 @@ class Loan(AccountsController):
) )
def validate_cost_center(self): def validate_cost_center(self):
if not self.cost_center and self.rate_of_interest != 0: if not self.cost_center and self.rate_of_interest != 0.0:
self.cost_center = frappe.db.get_value("Company", self.company, "cost_center") self.cost_center = frappe.db.get_value("Company", self.company, "cost_center")
if not self.cost_center: if not self.cost_center:
frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0")) frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0"))
def on_submit(self): def on_submit(self):
self.link_loan_security_pledge() self.link_loan_security_pledge()
# Interest accrual for backdated term loans
self.accrue_loan_interest()
def on_cancel(self): def on_cancel(self):
self.unlink_loan_security_pledge() self.unlink_loan_security_pledge()
self.ignore_linked_doctypes = ["GL Entry"] self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def set_missing_fields(self): def set_missing_fields(self):
if not self.company: if not self.company:
@@ -187,6 +189,16 @@ class Loan(AccountsController):
self.db_set("maximum_loan_amount", maximum_loan_value) 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): def unlink_loan_security_pledge(self):
pledges = frappe.get_all("Loan Security Pledge", fields=["name"], filters={"loan": self.name}) pledges = frappe.get_all("Loan Security Pledge", fields=["name"], filters={"loan": self.name})
pledge_list = [d.name for d in pledges] pledge_list = [d.name for d in pledges]
@@ -330,6 +342,22 @@ def get_loan_application(loan_application):
return loan.as_dict() return loan.as_dict()
@frappe.whitelist()
def close_unsecured_term_loan(loan):
loan_details = frappe.db.get_value(
"Loan", {"name": loan}, ["status", "is_term_loan", "is_secured_loan"], as_dict=1
)
if (
loan_details.status == "Loan Closure Requested"
and loan_details.is_term_loan
and not loan_details.is_secured_loan
):
frappe.db.set_value("Loan", loan, "status", "Closed")
else:
frappe.throw(_("Cannot close this loan until full repayment"))
def close_loan(loan, total_amount_paid): def close_loan(loan, total_amount_paid):
frappe.db.set_value("Loan", loan, "total_amount_paid", total_amount_paid) frappe.db.set_value("Loan", loan, "total_amount_paid", total_amount_paid)
frappe.db.set_value("Loan", loan, "status", "Closed") frappe.db.set_value("Loan", loan, "status", "Closed")

View File

@@ -29,7 +29,7 @@ class LoanDisbursement(AccountsController):
def on_cancel(self): def on_cancel(self):
self.set_status_and_amounts(cancel=1) self.set_status_and_amounts(cancel=1)
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
self.ignore_linked_doctypes = ["GL Entry"] self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def set_missing_values(self): def set_missing_values(self):
if not self.disbursement_date: if not self.disbursement_date:

View File

@@ -32,7 +32,7 @@ class LoanInterestAccrual(AccountsController):
self.update_is_accrued() self.update_is_accrued()
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
self.ignore_linked_doctypes = ["GL Entry"] self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def update_is_accrued(self): def update_is_accrued(self):
frappe.db.set_value("Repayment Schedule", self.repayment_schedule_name, "is_accrued", 0) frappe.db.set_value("Repayment Schedule", self.repayment_schedule_name, "is_accrued", 0)

View File

@@ -41,7 +41,7 @@ class LoanRepayment(AccountsController):
self.check_future_accruals() self.check_future_accruals()
self.update_repayment_schedule(cancel=1) self.update_repayment_schedule(cancel=1)
self.mark_as_unpaid() self.mark_as_unpaid()
self.ignore_linked_doctypes = ["GL Entry"] self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
def set_missing_values(self, amounts): def set_missing_values(self, amounts):

View File

@@ -42,7 +42,7 @@ class LoanWriteOff(AccountsController):
def on_cancel(self): def on_cancel(self):
self.update_outstanding_amount(cancel=1) self.update_outstanding_amount(cancel=1)
self.ignore_linked_doctypes = ["GL Entry"] self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
def update_outstanding_amount(self, cancel=0): def update_outstanding_amount(self, cancel=0):

View File

@@ -81,7 +81,7 @@ frappe.ui.form.on("BOM", {
} }
) )
if (!frm.doc.__islocal && frm.doc.docstatus<2) { if (!frm.is_new() && frm.doc.docstatus<2) {
frm.add_custom_button(__("Update Cost"), function() { frm.add_custom_button(__("Update Cost"), function() {
frm.events.update_cost(frm, true); frm.events.update_cost(frm, true);
}); });
@@ -93,10 +93,12 @@ frappe.ui.form.on("BOM", {
}); });
} }
frm.add_custom_button(__("New Version"), function() { if (!frm.is_new() && !frm.doc.docstatus == 0) {
let new_bom = frappe.model.copy_doc(frm.doc); frm.add_custom_button(__("New Version"), function() {
frappe.set_route("Form", "BOM", new_bom.name); let new_bom = frappe.model.copy_doc(frm.doc);
}); frappe.set_route("Form", "BOM", new_bom.name);
});
}
if(frm.doc.docstatus==1) { if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() { frm.add_custom_button(__("Work Order"), function() {

View File

@@ -1,11 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import functools import functools
import re import re
from collections import deque from collections import deque
from operator import itemgetter from operator import itemgetter
from typing import List from typing import Dict, List
import frappe import frappe
from frappe import _ from frappe import _
@@ -189,6 +189,7 @@ class BOM(WebsiteGenerator):
self.validate_transfer_against() self.validate_transfer_against()
self.set_routing_operations() self.set_routing_operations()
self.validate_operations() self.validate_operations()
self.update_exploded_items(save=False)
self.calculate_cost() self.calculate_cost()
self.update_stock_qty() self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
@@ -386,40 +387,14 @@ class BOM(WebsiteGenerator):
existing_bom_cost = self.total_cost existing_bom_cost = self.total_cost
for d in self.get("items"):
if not d.item_code:
continue
rate = self.get_rm_rate(
{
"company": self.company,
"item_code": d.item_code,
"bom_no": d.bom_no,
"qty": d.qty,
"uom": d.uom,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
}
)
if rate:
d.rate = rate
d.amount = flt(d.rate) * flt(d.qty)
d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.base_amount = flt(d.amount) * flt(self.conversion_rate)
if save:
d.db_update()
if self.docstatus == 1: if self.docstatus == 1:
self.flags.ignore_validate_update_after_submit = True self.flags.ignore_validate_update_after_submit = True
self.calculate_cost(update_hour_rate)
self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)
if save: if save:
self.db_update() self.db_update()
self.update_exploded_items(save=save)
# update parent BOMs # update parent BOMs
if self.total_cost != existing_bom_cost and update_parent: if self.total_cost != existing_bom_cost and update_parent:
parent_boms = frappe.db.sql_list( parent_boms = frappe.db.sql_list(
@@ -608,11 +583,15 @@ class BOM(WebsiteGenerator):
bom_list.reverse() bom_list.reverse()
return bom_list return bom_list
def calculate_cost(self, update_hour_rate=False): def calculate_cost(self, save_updates=False, update_hour_rate=False):
"""Calculate bom totals""" """Calculate bom totals"""
self.calculate_op_cost(update_hour_rate) self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost() self.calculate_rm_cost(save=save_updates)
self.calculate_sm_cost() self.calculate_sm_cost(save=save_updates)
if save_updates:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()
self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost
self.base_total_cost = ( self.base_total_cost = (
self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost
@@ -654,12 +633,26 @@ class BOM(WebsiteGenerator):
if update_hour_rate: if update_hour_rate:
row.db_update() row.db_update()
def calculate_rm_cost(self): def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals""" """Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0 total_rm_cost = 0
base_total_rm_cost = 0 base_total_rm_cost = 0
for d in self.get("items"): for d in self.get("items"):
old_rate = d.rate
d.rate = self.get_rm_rate(
{
"company": self.company,
"item_code": d.item_code,
"bom_no": d.bom_no,
"qty": d.qty,
"uom": d.uom,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
}
)
d.base_rate = flt(d.rate) * flt(self.conversion_rate) d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty")) d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty"))
d.base_amount = d.amount * flt(self.conversion_rate) d.base_amount = d.amount * flt(self.conversion_rate)
@@ -669,11 +662,13 @@ class BOM(WebsiteGenerator):
total_rm_cost += d.amount total_rm_cost += d.amount
base_total_rm_cost += d.base_amount base_total_rm_cost += d.base_amount
if save and (old_rate != d.rate):
d.db_update()
self.raw_material_cost = total_rm_cost self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_total_rm_cost self.base_raw_material_cost = base_total_rm_cost
def calculate_sm_cost(self): def calculate_sm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals""" """Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0 total_sm_cost = 0
base_total_sm_cost = 0 base_total_sm_cost = 0
@@ -688,10 +683,45 @@ class BOM(WebsiteGenerator):
) )
total_sm_cost += d.amount total_sm_cost += d.amount
base_total_sm_cost += d.base_amount base_total_sm_cost += d.base_amount
if save:
d.db_update()
self.scrap_material_cost = total_sm_cost self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost self.base_scrap_material_cost = base_total_sm_cost
def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM."
rm_rate_map = self.get_rm_rate_map()
for row in self.get("exploded_items"):
old_rate = flt(row.rate)
row.rate = rm_rate_map.get(row.item_code)
row.amount = flt(row.stock_qty) * flt(row.rate)
if old_rate != row.rate:
# Only db_update if changed
row.db_update()
def get_rm_rate_map(self) -> Dict[str, float]:
"Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM."
rm_rate_map = {}
for item in self.get("items"):
if item.bom_no:
# Get Item-Rate from Subassembly BOM
explosion_items = frappe.get_all(
"BOM Explosion Item",
filters={"parent": item.bom_no},
fields=["item_code", "rate"],
order_by=None, # to avoid sort index creation at db level (granular change)
)
explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items}
rm_rate_map.update(explosion_item_rate)
else:
rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0)
return rm_rate_map
def update_exploded_items(self, save=True): def update_exploded_items(self, save=True):
"""Update Flat BOM, following will be correct data""" """Update Flat BOM, following will be correct data"""
self.get_exploded_items() self.get_exploded_items()
@@ -902,44 +932,46 @@ def get_bom_item_rate(args, bom_doc):
return flt(rate) return flt(rate)
def get_valuation_rate(args): def get_valuation_rate(data):
"""Get weighted average of valuation rate from all warehouses""" """
1) Get average valuation rate from all warehouses
2) If no value, get last valuation rate from SLE
3) If no value, get valuation rate from Item
"""
from frappe.query_builder.functions import Sum
total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0 item_code, company = data.get("item_code"), data.get("company")
item_bins = frappe.db.sql( valuation_rate = 0.0
"""
select
bin.actual_qty, bin.stock_value
from
`tabBin` bin, `tabWarehouse` warehouse
where
bin.item_code=%(item)s
and bin.warehouse = warehouse.name
and warehouse.company=%(company)s""",
{"item": args["item_code"], "company": args["company"]},
as_dict=1,
)
for d in item_bins: bin_table = frappe.qb.DocType("Bin")
total_qty += flt(d.actual_qty) wh_table = frappe.qb.DocType("Warehouse")
total_value += flt(d.stock_value) item_valuation = (
frappe.qb.from_(bin_table)
.join(wh_table)
.on(bin_table.warehouse == wh_table.name)
.select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate"))
.where((bin_table.item_code == item_code) & (wh_table.company == company))
).run(as_dict=True)[0]
if total_qty: valuation_rate = item_valuation.get("valuation_rate")
valuation_rate = total_value / total_qty
if valuation_rate <= 0: if (valuation_rate is not None) and valuation_rate <= 0:
last_valuation_rate = frappe.db.sql( # Explicit null value check. If None, Bins don't exist, neither does SLE
"""select valuation_rate sle = frappe.qb.DocType("Stock Ledger Entry")
from `tabStock Ledger Entry` last_val_rate = (
where item_code = %s and valuation_rate > 0 and is_cancelled = 0 frappe.qb.from_(sle)
order by posting_date desc, posting_time desc, creation desc limit 1""", .select(sle.valuation_rate)
args["item_code"], .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
) .orderby(sle.posting_date, order=frappe.qb.desc)
.orderby(sle.posting_time, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run(as_dict=True)
valuation_rate = flt(last_valuation_rate[0][0]) if last_valuation_rate else 0 valuation_rate = flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0
if not valuation_rate: if not valuation_rate:
valuation_rate = frappe.db.get_value("Item", args["item_code"], "valuation_rate") valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate")
return flt(valuation_rate) return flt(valuation_rate)
@@ -1125,39 +1157,6 @@ def get_children(parent=None, is_root=False, **filters):
return bom_items return bom_items
def get_boms_in_bottom_up_order(bom_no=None):
def _get_parent(bom_no):
return frappe.db.sql_list(
"""
select distinct bom_item.parent from `tabBOM Item` bom_item
where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM'
and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1)
""",
bom_no,
)
count = 0
bom_list = []
if bom_no:
bom_list.append(bom_no)
else:
# get all leaf BOMs
bom_list = frappe.db.sql_list(
"""select name from `tabBOM` bom
where docstatus=1 and is_active=1
and not exists(select bom_no from `tabBOM Item`
where parent=bom.name and ifnull(bom_no, '')!='')"""
)
while count < len(bom_list):
for child_bom in _get_parent(bom_list[count]):
if child_bom not in bom_list:
bom_list.append(child_bom)
count += 1
return bom_list
def add_additional_cost(stock_entry, work_order): def add_additional_cost(stock_entry, work_order):
# Add non stock items cost in the additional cost # Add non stock items cost in the additional cost
stock_entry.additional_costs = [] stock_entry.additional_costs = []

View File

@@ -11,7 +11,9 @@ from frappe.utils import cstr, flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, 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.manufacturing.doctype.bom_update_log.test_bom_update_log import (
update_cost_in_all_boms_in_test,
)
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation, create_stock_reconciliation,
@@ -69,26 +71,31 @@ class TestBOM(FrappeTestCase):
def test_update_bom_cost_in_all_boms(self): def test_update_bom_cost_in_all_boms(self):
# get current rate for '_Test Item 2' # get current rate for '_Test Item 2'
rm_rate = frappe.db.sql( bom_rates = frappe.db.get_values(
"""select rate from `tabBOM Item` "BOM Item",
where parent='BOM-_Test Item Home Desktop Manufactured-001' {
and item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""" "parent": "BOM-_Test Item Home Desktop Manufactured-001",
"item_code": "_Test Item 2",
"docstatus": 1,
},
fieldname=["rate", "base_rate"],
as_dict=True,
) )
rm_rate = rm_rate[0][0] if rm_rate else 0 rm_base_rate = bom_rates[0].get("base_rate") if bom_rates else 0
# Reset item valuation rate # Reset item valuation rate
reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_rate + 10) reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_base_rate + 10)
# update cost of all BOMs based on latest valuation rate # update cost of all BOMs based on latest valuation rate
update_cost() update_cost_in_all_boms_in_test()
# check if new valuation rate updated in all BOMs # check if new valuation rate updated in all BOMs
for d in frappe.db.sql( for d in frappe.db.sql(
"""select rate from `tabBOM Item` """select base_rate from `tabBOM Item`
where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""", where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""",
as_dict=1, as_dict=1,
): ):
self.assertEqual(d.rate, rm_rate + 10) self.assertEqual(d.base_rate, rm_base_rate + 10)
def test_bom_cost(self): def test_bom_cost(self):
bom = frappe.copy_doc(test_records[2]) bom = frappe.copy_doc(test_records[2])

View File

@@ -32,6 +32,7 @@
"is_active": 1, "is_active": 1,
"is_default": 1, "is_default": 1,
"item": "_Test Item Home Desktop Manufactured", "item": "_Test Item Home Desktop Manufactured",
"company": "_Test Company",
"quantity": 1.0 "quantity": 1.0
}, },
{ {

View File

@@ -169,13 +169,15 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-10-08 16:21:29.386212", "modified": "2022-05-27 13:42:23.305455",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Explosion Item", "name": "BOM Explosion Item",
"naming_rule": "Random",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -0,0 +1,55 @@
{
"actions": [],
"autoname": "autoincrement",
"creation": "2022-05-31 17:34:39.825537",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"level",
"batch_no",
"boms_updated",
"status"
],
"fields": [
{
"fieldname": "level",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Level"
},
{
"fieldname": "batch_no",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Batch No."
},
{
"fieldname": "boms_updated",
"fieldtype": "Long Text",
"hidden": 1,
"in_list_view": 1,
"label": "BOMs Updated"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Pending\nCompleted",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-06-06 14:50:35.161062",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Update Batch",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class BOMUpdateBatch(Document):
pass

View File

@@ -13,6 +13,10 @@
"update_type", "update_type",
"status", "status",
"error_log", "error_log",
"progress_section",
"current_level",
"processed_boms",
"bom_batches",
"amended_from" "amended_from"
], ],
"fields": [ "fields": [
@@ -63,13 +67,36 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Error Log", "label": "Error Log",
"options": "Error Log" "options": "Error Log"
},
{
"collapsible": 1,
"depends_on": "eval: doc.update_type == \"Update Cost\"",
"fieldname": "progress_section",
"fieldtype": "Section Break",
"label": "Progress"
},
{
"fieldname": "processed_boms",
"fieldtype": "Long Text",
"hidden": 1,
"label": "Processed BOMs"
},
{
"fieldname": "bom_batches",
"fieldtype": "Table",
"options": "BOM Update Batch"
},
{
"fieldname": "current_level",
"fieldtype": "Int",
"label": "Current Level"
} }
], ],
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-03-31 12:51:44.885102", "modified": "2022-06-06 15:15:23.883251",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Update Log", "name": "BOM Update Log",

View File

@@ -1,13 +1,20 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
from typing import Dict, List, Literal, Optional import json
from typing import Any, Dict, List, Optional, Tuple, Union
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cstr, flt from frappe.utils import cint, cstr
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import (
get_leaf_boms,
get_next_higher_level_boms,
handle_exception,
replace_bom,
set_values_in_log,
)
class BOMMissingError(frappe.ValidationError): class BOMMissingError(frappe.ValidationError):
@@ -20,6 +27,8 @@ class BOMUpdateLog(Document):
self.validate_boms_are_specified() self.validate_boms_are_specified()
self.validate_same_bom() self.validate_same_bom()
self.validate_bom_items() self.validate_bom_items()
else:
self.validate_bom_cost_update_in_progress()
self.status = "Queued" self.status = "Queued"
@@ -42,123 +51,184 @@ class BOMUpdateLog(Document):
if current_bom_item != new_bom_item: if current_bom_item != new_bom_item:
frappe.throw(_("The selected BOMs are not for the same item")) frappe.throw(_("The selected BOMs are not for the same item"))
def on_submit(self): def validate_bom_cost_update_in_progress(self):
if frappe.flags.in_test: "If another Cost Updation Log is still in progress, dont make new ones."
return
wip_log = frappe.get_all(
"BOM Update Log",
{"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]},
limit_page_length=1,
)
if wip_log:
log_link = frappe.utils.get_link_to_form("BOM Update Log", wip_log[0].name)
frappe.throw(
_("BOM Updation already in progress. Please wait until {0} is complete.").format(log_link),
title=_("Note"),
)
def on_submit(self):
if self.update_type == "Replace BOM": if self.update_type == "Replace BOM":
boms = {"current_bom": self.current_bom, "new_bom": self.new_bom} boms = {"current_bom": self.current_bom, "new_bom": self.new_bom}
frappe.enqueue( frappe.enqueue(
method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job", method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_replace_bom_job",
doc=self, doc=self,
boms=boms, boms=boms,
timeout=40000, timeout=40000,
now=frappe.flags.in_test,
) )
else: else:
frappe.enqueue( process_boms_cost_level_wise(self)
method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
doc=self,
update_type="Update Cost",
timeout=40000,
)
def replace_bom(boms: Dict) -> None: def run_replace_bom_job(
"""Replace current BOM with new BOM in parent BOMs."""
current_bom = boms.get("current_bom")
new_bom = boms.get("new_bom")
unit_cost = get_new_bom_unit_cost(new_bom)
update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
frappe.cache().delete_key("bom_children")
parent_boms = get_parent_boms(new_bom)
for bom in parent_boms:
bom_obj = frappe.get_doc("BOM", bom)
# this is only used for versioning and we do not want
# to make separate db calls by using load_doc_before_save
# which proves to be expensive while doing bulk replace
bom_obj._doc_before_save = bom_obj
bom_obj.update_exploded_items()
bom_obj.calculate_cost()
bom_obj.update_parent_cost()
bom_obj.db_update()
if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version:
bom_obj.save_version()
def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
bom_item = frappe.qb.DocType("BOM Item")
(
frappe.qb.update(bom_item)
.set(bom_item.bom_no, new_bom)
.set(bom_item.rate, unit_cost)
.set(bom_item.amount, (bom_item.stock_qty * unit_cost))
.where(
(bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
)
).run()
def get_parent_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
bom_list = bom_list or []
bom_item = frappe.qb.DocType("BOM Item")
parents = (
frappe.qb.from_(bom_item)
.select(bom_item.parent)
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
.run(as_dict=True)
)
for d in parents:
if new_bom == d.parent:
frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
bom_list.append(d.parent)
get_parent_boms(d.parent, bom_list)
return list(set(bom_list))
def get_new_bom_unit_cost(new_bom: str) -> float:
bom = frappe.qb.DocType("BOM")
new_bom_unitcost = (
frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run()
)
return flt(new_bom_unitcost[0][0])
def run_bom_job(
doc: "BOMUpdateLog", doc: "BOMUpdateLog",
boms: Optional[Dict[str, str]] = None, boms: Optional[Dict[str, str]] = None,
update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM",
) -> None: ) -> None:
try: try:
doc.db_set("status", "In Progress") doc.db_set("status", "In Progress")
if not frappe.flags.in_test: if not frappe.flags.in_test:
frappe.db.commit() frappe.db.commit()
frappe.db.auto_commit_on_many_writes = 1 frappe.db.auto_commit_on_many_writes = 1
boms = frappe._dict(boms or {}) boms = frappe._dict(boms or {})
replace_bom(boms, doc.name)
if update_type == "Replace BOM":
replace_bom(boms)
else:
update_cost()
doc.db_set("status", "Completed") doc.db_set("status", "Completed")
except Exception: except Exception:
frappe.db.rollback() handle_exception(doc)
error_log = doc.log_error("BOM Update Tool Error")
doc.db_set("status", "Failed")
doc.db_set("error_log", error_log.name)
finally: finally:
frappe.db.auto_commit_on_many_writes = 0 frappe.db.auto_commit_on_many_writes = 0
frappe.db.commit() # nosemgrep
if not frappe.flags.in_test:
frappe.db.commit() # nosemgrep
def process_boms_cost_level_wise(
update_doc: "BOMUpdateLog", parent_boms: List[str] = None
) -> Union[None, Tuple]:
"Queue jobs at the start of new BOM Level in 'Update Cost' Jobs."
current_boms = {}
values = {}
if update_doc.status == "Queued":
# First level yet to process. On Submit.
current_level = 0
current_boms = get_leaf_boms()
values = {
"processed_boms": json.dumps({}),
"status": "In Progress",
"current_level": current_level,
}
else:
# Resume next level. via Cron Job.
if not parent_boms:
return
current_level = cint(update_doc.current_level) + 1
# Process the next level BOMs. Stage parents as current BOMs.
current_boms = parent_boms.copy()
values = {"current_level": current_level}
set_values_in_log(update_doc.name, values, commit=True)
queue_bom_cost_jobs(current_boms, update_doc, current_level)
def queue_bom_cost_jobs(
current_boms_list: List[str], update_doc: "BOMUpdateLog", current_level: int
) -> None:
"Queue batches of 20k BOMs of the same level to process parallelly"
batch_no = 0
while current_boms_list:
batch_no += 1
batch_size = 20_000
boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs
# update list to exclude 20K (queued) BOMs
current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else []
batch_row = update_doc.append(
"bom_batches", {"level": current_level, "batch_no": batch_no, "status": "Pending"}
)
batch_row.db_insert()
frappe.enqueue(
method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level",
doc=update_doc,
bom_list=boms_to_process,
batch_name=batch_row.name,
queue="long",
now=frappe.flags.in_test,
)
def resume_bom_cost_update_jobs():
"""
1. Checks for In Progress BOM Update Log.
2. Checks if this job has completed the _current level_.
3. If current level is complete, get parent BOMs and start next level.
4. If no parents, mark as Complete.
5. If current level is WIP, skip the Log.
Called every 5 minutes via Cron job.
"""
in_progress_logs = frappe.db.get_all(
"BOM Update Log",
{"update_type": "Update Cost", "status": "In Progress"},
["name", "processed_boms", "current_level"],
)
if not in_progress_logs:
return
for log in in_progress_logs:
# check if all log batches of current level are processed
bom_batches = frappe.db.get_all(
"BOM Update Batch",
{"parent": log.name, "level": log.current_level},
["name", "boms_updated", "status"],
)
incomplete_level = any(row.get("status") == "Pending" for row in bom_batches)
if not bom_batches or incomplete_level:
continue
# Prep parent BOMs & updated processed BOMs for next level
current_boms, processed_boms = get_processed_current_boms(log, bom_batches)
parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms)
# Unset processed BOMs if log is complete, it is used for next level BOMs
set_values_in_log(
log.name,
values={
"processed_boms": json.dumps([] if not parent_boms else processed_boms),
"status": "Completed" if not parent_boms else "In Progress",
},
commit=True,
)
if parent_boms: # there is a next level to process
process_boms_cost_level_wise(
update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms
)
def get_processed_current_boms(
log: Dict[str, Any], bom_batches: Dict[str, Any]
) -> Tuple[List[str], Dict[str, Any]]:
"""
Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field
and into current boms list.
"""
processed_boms = json.loads(log.processed_boms) if log.processed_boms else {}
current_boms = []
for row in bom_batches:
boms_updated = json.loads(row.boms_updated)
current_boms.extend(boms_updated)
boms_updated_dict = {bom: True for bom in boms_updated}
processed_boms.update(boms_updated_dict)
return current_boms, processed_boms

View File

@@ -0,0 +1,225 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import copy
import json
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
if TYPE_CHECKING:
from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog
import frappe
from frappe import _
def replace_bom(boms: Dict, log_name: str) -> None:
"Replace current BOM with new BOM in parent BOMs."
current_bom = boms.get("current_bom")
new_bom = boms.get("new_bom")
unit_cost = get_bom_unit_cost(new_bom)
update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
frappe.cache().delete_key("bom_children")
parent_boms = get_ancestor_boms(new_bom)
for bom in parent_boms:
bom_obj = frappe.get_doc("BOM", bom)
# this is only used for versioning and we do not want
# to make separate db calls by using load_doc_before_save
# which proves to be expensive while doing bulk replace
bom_obj._doc_before_save = copy.deepcopy(bom_obj)
bom_obj.update_exploded_items()
bom_obj.calculate_cost()
bom_obj.update_parent_cost()
bom_obj.db_update()
bom_obj.flags.updater_reference = {
"doctype": "BOM Update Log",
"docname": log_name,
"label": _("via BOM Update Tool"),
}
bom_obj.save_version()
def update_cost_in_level(
doc: "BOMUpdateLog", bom_list: List[str], batch_name: Union[int, str]
) -> None:
"Updates Cost for BOMs within a given level. Runs via background jobs."
try:
status = frappe.db.get_value("BOM Update Log", doc.name, "status")
if status == "Failed":
return
update_cost_in_boms(bom_list=bom_list) # main updation logic
bom_batch = frappe.qb.DocType("BOM Update Batch")
(
frappe.qb.update(bom_batch)
.set(bom_batch.boms_updated, json.dumps(bom_list))
.set(bom_batch.status, "Completed")
.where(bom_batch.name == batch_name)
).run()
except Exception:
handle_exception(doc)
finally:
if not frappe.flags.in_test:
frappe.db.commit() # nosemgrep
def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
"Recursively get all ancestors of BOM."
bom_list = bom_list or []
bom_item = frappe.qb.DocType("BOM Item")
parents = (
frappe.qb.from_(bom_item)
.select(bom_item.parent)
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
.run(as_dict=True)
)
for d in parents:
if new_bom == d.parent:
frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
bom_list.append(d.parent)
get_ancestor_boms(d.parent, bom_list)
return list(set(bom_list))
def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
bom_item = frappe.qb.DocType("BOM Item")
(
frappe.qb.update(bom_item)
.set(bom_item.bom_no, new_bom)
.set(bom_item.rate, unit_cost)
.set(bom_item.amount, (bom_item.stock_qty * unit_cost))
.where(
(bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
)
).run()
def get_bom_unit_cost(bom_name: str) -> float:
bom = frappe.qb.DocType("BOM")
new_bom_unitcost = (
frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == bom_name).run()
)
return frappe.utils.flt(new_bom_unitcost[0][0])
def update_cost_in_boms(bom_list: List[str]) -> None:
"Updates cost in given BOMs. Returns current and total updated BOMs."
for index, bom in enumerate(bom_list):
bom_doc = frappe.get_doc("BOM", bom, for_update=True)
bom_doc.calculate_cost(save_updates=True, update_hour_rate=True)
bom_doc.db_update()
if (index % 50 == 0) and not frappe.flags.in_test:
frappe.db.commit() # nosemgrep
def get_next_higher_level_boms(
child_boms: List[str], processed_boms: Dict[str, bool]
) -> List[str]:
"Generate immediate higher level dependants with no unresolved dependencies (children)."
def _all_children_are_processed(parent_bom):
child_boms = dependency_map.get(parent_bom)
return all(processed_boms.get(bom) for bom in child_boms)
dependants_map, dependency_map = _generate_dependence_map()
dependants = []
for bom in child_boms:
# generate list of immediate dependants
parents = dependants_map.get(bom) or []
dependants.extend(parents)
dependants = set(dependants) # remove duplicates
resolved_dependants = set()
# consider only if children are all resolved
for parent_bom in dependants:
if _all_children_are_processed(parent_bom):
resolved_dependants.add(parent_bom)
return list(resolved_dependants)
def get_leaf_boms() -> List[str]:
"Get BOMs that have no dependencies."
return frappe.db.sql_list(
"""select name from `tabBOM` bom
where docstatus=1 and is_active=1
and not exists(select bom_no from `tabBOM Item`
where parent=bom.name and ifnull(bom_no, '')!='')"""
)
def _generate_dependence_map() -> defaultdict:
"""
Generate maps such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }.
Here BOM-1 is the leaf/lower level node/dependency.
The list contains one level higher nodes/dependants that depend on BOM-1.
Generate and return the reverse as well.
"""
bom = frappe.qb.DocType("BOM")
bom_item = frappe.qb.DocType("BOM Item")
bom_items = (
frappe.qb.from_(bom_item)
.join(bom)
.on(bom_item.parent == bom.name)
.select(bom_item.bom_no, bom_item.parent)
.where(
(bom_item.bom_no.isnotnull())
& (bom_item.bom_no != "")
& (bom.docstatus == 1)
& (bom.is_active == 1)
& (bom_item.parenttype == "BOM")
)
).run(as_dict=True)
child_parent_map = defaultdict(list)
parent_child_map = defaultdict(list)
for row in bom_items:
child_parent_map[row.bom_no].append(row.parent)
parent_child_map[row.parent].append(row.bom_no)
return child_parent_map, parent_child_map
def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = False) -> None:
"Update BOM Update Log record."
if not values:
return
bom_update_log = frappe.qb.DocType("BOM Update Log")
query = frappe.qb.update(bom_update_log).where(bom_update_log.name == log_name)
for key, value in values.items():
query = query.set(key, value)
query.run()
if commit and not frappe.flags.in_test:
frappe.db.commit() # nosemgrep
def handle_exception(doc: "BOMUpdateLog") -> None:
"Rolls back and fails BOM Update Log."
frappe.db.rollback()
error_log = doc.log_error("BOM Update Tool Error")
set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name})

View File

@@ -6,9 +6,12 @@ from frappe.tests.utils import FrappeTestCase
from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import ( from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import (
BOMMissingError, BOMMissingError,
run_bom_job, resume_bom_cost_update_jobs,
)
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import (
enqueue_replace_bom,
enqueue_update_cost,
) )
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
test_records = frappe.get_test_records("BOM") test_records = frappe.get_test_records("BOM")
@@ -31,17 +34,12 @@ class TestBOMUpdateLog(FrappeTestCase):
def tearDown(self): def tearDown(self):
frappe.db.rollback() frappe.db.rollback()
if self._testMethodName == "test_bom_update_log_completion":
# clear logs and delete BOM created via setUp
frappe.db.delete("BOM Update Log")
self.new_bom_doc.cancel()
self.new_bom_doc.delete()
# explicitly commit and restore to original state
frappe.db.commit() # nosemgrep
def test_bom_update_log_validate(self): def test_bom_update_log_validate(self):
"Test if BOM presence is validated." """
1) Test if BOM presence is validated.
2) Test if same BOMs are validated.
3) Test of non-existent BOM is validated.
"""
with self.assertRaises(BOMMissingError): with self.assertRaises(BOMMissingError):
enqueue_replace_bom(boms={}) enqueue_replace_bom(boms={})
@@ -52,45 +50,22 @@ class TestBOMUpdateLog(FrappeTestCase):
with self.assertRaises(frappe.ValidationError): with self.assertRaises(frappe.ValidationError):
enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM")) enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM"))
def test_bom_update_log_queueing(self):
"Test if BOM Update Log is created and queued."
log = enqueue_replace_bom(
boms=self.boms,
)
self.assertEqual(log.docstatus, 1)
self.assertEqual(log.status, "Queued")
def test_bom_update_log_completion(self): def test_bom_update_log_completion(self):
"Test if BOM Update Log handles job completion correctly." "Test if BOM Update Log handles job completion correctly."
log = enqueue_replace_bom( log = enqueue_replace_bom(boms=self.boms)
boms=self.boms,
)
# Explicitly commits log, new bom (setUp) and replacement impact.
# Is run via background jobs IRL
run_bom_job(
doc=log,
boms=self.boms,
update_type="Replace BOM",
)
log.reload() log.reload()
self.assertEqual(log.status, "Completed") self.assertEqual(log.status, "Completed")
# teardown (undo replace impact) due to commit
boms = frappe._dict( def update_cost_in_all_boms_in_test():
current_bom=self.boms.new_bom, """
new_bom=self.boms.current_bom, Utility to run 'Update Cost' job in tests without Cron job until fully complete.
) """
log2 = enqueue_replace_bom( log = enqueue_update_cost() # create BOM Update Log
boms=self.boms,
) while log.status != "Completed":
run_bom_job( # Explicitly commits resume_bom_cost_update_jobs() # run cron job until complete
doc=log2, log.reload()
boms=boms,
update_type="Replace BOM", return log
)
self.assertEqual(log2.status, "Completed")

View File

@@ -10,8 +10,6 @@ if TYPE_CHECKING:
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from erpnext.manufacturing.doctype.bom.bom import get_boms_in_bottom_up_order
class BOMUpdateTool(Document): class BOMUpdateTool(Document):
pass pass
@@ -40,14 +38,13 @@ def enqueue_update_cost() -> "BOMUpdateLog":
def auto_update_latest_price_in_all_boms() -> None: def auto_update_latest_price_in_all_boms() -> None:
"""Called via hooks.py.""" """Called via hooks.py."""
if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"): if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"):
update_cost() wip_log = frappe.get_all(
"BOM Update Log",
{"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]},
def update_cost() -> None: limit_page_length=1,
"""Updates Cost for all BOMs from bottom to top.""" )
bom_list = get_boms_in_bottom_up_order() if not wip_log:
for bom in bom_list: create_bom_update_log(update_type="Update Cost")
frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True)
def create_bom_update_log( def create_bom_update_log(

View File

@@ -1,11 +1,13 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import frappe import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_bom from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost update_cost_in_all_boms_in_test,
)
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
@@ -15,6 +17,9 @@ test_records = frappe.get_test_records("BOM")
class TestBOMUpdateTool(FrappeTestCase): class TestBOMUpdateTool(FrappeTestCase):
"Test major functions run via BOM Update Tool." "Test major functions run via BOM Update Tool."
def tearDown(self):
frappe.db.rollback()
def test_replace_bom(self): def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001" current_bom = "BOM-_Test Item Home Desktop Manufactured-001"
@@ -23,15 +28,10 @@ class TestBOMUpdateTool(FrappeTestCase):
bom_doc.insert() bom_doc.insert()
boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name) boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name)
replace_bom(boms) enqueue_replace_bom(boms=boms)
self.assertFalse(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", current_bom)) self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1}))
self.assertTrue(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", bom_doc.name)) self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1}))
# reverse, as it affects other testcases
boms.current_bom = bom_doc.name
boms.new_bom = current_bom
replace_bom(boms)
def test_bom_cost(self): def test_bom_cost(self):
for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]: for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]:
@@ -52,13 +52,13 @@ class TestBOMUpdateTool(FrappeTestCase):
self.assertEqual(doc.total_cost, 200) self.assertEqual(doc.total_cost, 200)
frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 200) frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 200)
update_cost() update_cost_in_all_boms_in_test()
doc.load_from_db() doc.load_from_db()
self.assertEqual(doc.total_cost, 300) self.assertEqual(doc.total_cost, 300)
frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 100) frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 100)
update_cost() update_cost_in_all_boms_in_test()
doc.load_from_db() doc.load_from_db()
self.assertEqual(doc.total_cost, 200) self.assertEqual(doc.total_cost, 200)

View File

@@ -621,7 +621,7 @@ class JobCard(Document):
self.set_status(update_status) self.set_status(update_status)
def set_status(self, update_status=False): def set_status(self, update_status=False):
if self.status == "On Hold": if self.status == "On Hold" and self.docstatus == 0:
return return
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]

View File

@@ -798,7 +798,6 @@ def make_bom(**args):
for item in args.raw_materials: for item in args.raw_materials:
item_doc = frappe.get_doc("Item", item) item_doc = frappe.get_doc("Item", item)
bom.append( bom.append(
"items", "items",
{ {

View File

@@ -417,7 +417,7 @@ class TestWorkOrder(FrappeTestCase):
"doctype": "Item Price", "doctype": "Item Price",
"item_code": "_Test FG Non Stock Item", "item_code": "_Test FG Non Stock Item",
"price_list_rate": 1000, "price_list_rate": 1000,
"price_list": "Standard Buying", "price_list": "_Test Price List India",
} }
).insert(ignore_permissions=True) ).insert(ignore_permissions=True)
@@ -426,8 +426,17 @@ class TestWorkOrder(FrappeTestCase):
item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100 item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100
) )
if not frappe.db.get_value("BOM", {"item": fg_item}): if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}):
make_bom(item=fg_item, rate=1000, raw_materials=["_Test FG Item", "_Test FG Non Stock Item"]) bom = make_bom(
item=fg_item,
rate=1000,
raw_materials=["_Test FG Item", "_Test FG Non Stock Item"],
do_not_save=True,
)
bom.rm_cost_as_per = "Price List" # non stock item won't have valuation rate
bom.buying_price_list = "_Test Price List India"
bom.currency = "INR"
bom.save()
wo = make_wo_order_test_record(production_item=fg_item) wo = make_wo_order_test_record(production_item=fg_item)

View File

@@ -402,14 +402,15 @@
"type": "Link" "type": "Link"
} }
], ],
"modified": "2022-01-13 17:40:09.474747", "modified": "2022-05-31 22:08:19.408223",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Manufacturing", "name": "Manufacturing",
"owner": "Administrator", "owner": "Administrator",
"parent_page": "", "parent_page": "",
"public": 1, "public": 1,
"restrict_to_domain": "Manufacturing", "quick_lists": [],
"restrict_to_domain": "",
"roles": [], "roles": [],
"sequence_id": 17.0, "sequence_id": 17.0,
"shortcuts": [ "shortcuts": [

View File

@@ -231,7 +231,6 @@ erpnext.patches.v13_0.updates_for_multi_currency_payroll
erpnext.patches.v13_0.update_reason_for_resignation_in_employee erpnext.patches.v13_0.update_reason_for_resignation_in_employee
execute:frappe.delete_doc("Report", "Quoted Item Comparison") execute:frappe.delete_doc("Report", "Quoted Item Comparison")
erpnext.patches.v13_0.update_member_email_address 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.update_pos_closing_entry_in_merge_log
erpnext.patches.v13_0.add_po_to_global_search erpnext.patches.v13_0.add_po_to_global_search
erpnext.patches.v13_0.update_returned_qty_in_pr_dn erpnext.patches.v13_0.update_returned_qty_in_pr_dn
@@ -372,3 +371,6 @@ erpnext.patches.v14_0.discount_accounting_separation
erpnext.patches.v14_0.delete_employee_transfer_property_doctype erpnext.patches.v14_0.delete_employee_transfer_property_doctype
erpnext.patches.v13_0.create_accounting_dimensions_in_orders erpnext.patches.v13_0.create_accounting_dimensions_in_orders
erpnext.patches.v13_0.set_per_billed_in_return_delivery_note erpnext.patches.v13_0.set_per_billed_in_return_delivery_note
execute:frappe.delete_doc("DocType", "Naming Series")
erpnext.patches.v13_0.set_payroll_entry_status
erpnext.patches.v13_0.job_card_status_on_hold

View File

@@ -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})

View File

@@ -1,6 +1,6 @@
import frappe 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(): def execute():

View File

@@ -0,0 +1,19 @@
import frappe
def execute():
job_cards = frappe.get_all(
"Job Card",
{"status": "On Hold", "docstatus": ("!=", 0)},
pluck="name",
)
for idx, job_card in enumerate(job_cards):
try:
doc = frappe.get_doc("Job Card", job_card)
doc.set_status()
doc.db_set("status", doc.status, update_modified=False)
if idx % 100 == 0:
frappe.db.commit()
except Exception:
continue

View File

@@ -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()

View File

@@ -5,7 +5,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import add_days, cint, cstr, date_diff, getdate, rounded from frappe.utils import add_days, cstr, date_diff, flt, getdate, rounded
from erpnext.hr.utils import ( from erpnext.hr.utils import (
get_holiday_dates_for_employee, get_holiday_dates_for_employee,
@@ -27,11 +27,14 @@ class EmployeeBenefitApplication(Document):
validate_active_employee(self.employee) validate_active_employee(self.employee)
self.validate_duplicate_on_payroll_period() self.validate_duplicate_on_payroll_period()
if not self.max_benefits: if not self.max_benefits:
self.max_benefits = get_max_benefits_remaining(self.employee, self.date, self.payroll_period) self.max_benefits = flt(
get_max_benefits_remaining(self.employee, self.date, self.payroll_period),
self.precision("max_benefits"),
)
if self.max_benefits and self.max_benefits > 0: if self.max_benefits and self.max_benefits > 0:
self.validate_max_benefit_for_component() self.validate_max_benefit_for_component()
self.validate_prev_benefit_claim() self.validate_prev_benefit_claim()
if self.remaining_benefit > 0: if self.remaining_benefit and self.remaining_benefit > 0:
self.validate_remaining_benefit_amount() self.validate_remaining_benefit_amount()
else: else:
frappe.throw( frappe.throw(
@@ -110,7 +113,7 @@ class EmployeeBenefitApplication(Document):
max_benefit_amount = 0 max_benefit_amount = 0
for employee_benefit in self.employee_benefits: for employee_benefit in self.employee_benefits:
self.validate_max_benefit(employee_benefit.earning_component) self.validate_max_benefit(employee_benefit.earning_component)
max_benefit_amount += employee_benefit.amount max_benefit_amount += flt(employee_benefit.amount)
if max_benefit_amount > self.max_benefits: if max_benefit_amount > self.max_benefits:
frappe.throw( frappe.throw(
_("Maximum benefit amount of employee {0} exceeds {1}").format( _("Maximum benefit amount of employee {0} exceeds {1}").format(
@@ -125,7 +128,8 @@ class EmployeeBenefitApplication(Document):
benefit_amount = 0 benefit_amount = 0
for employee_benefit in self.employee_benefits: for employee_benefit in self.employee_benefits:
if employee_benefit.earning_component == earning_component_name: if employee_benefit.earning_component == earning_component_name:
benefit_amount += employee_benefit.amount benefit_amount += flt(employee_benefit.amount)
prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given( prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given(
self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name
) )
@@ -207,26 +211,47 @@ def get_max_benefits_remaining(employee, on_date, payroll_period):
def calculate_lwp(employee, start_date, holidays, working_days): def calculate_lwp(employee, start_date, holidays, working_days):
lwp = 0 lwp = 0
holidays = "','".join(holidays) holidays = "','".join(holidays)
for d in range(working_days): for d in range(working_days):
dt = add_days(cstr(getdate(start_date)), d) date = add_days(cstr(getdate(start_date)), d)
leave = frappe.db.sql(
""" LeaveApplication = frappe.qb.DocType("Leave Application")
select t1.name, t1.half_day LeaveType = frappe.qb.DocType("Leave Type")
from `tabLeave Application` t1, `tabLeave Type` t2
where t2.name = t1.leave_type is_half_day = (
and t2.is_lwp = 1 frappe.qb.terms.Case()
and t1.docstatus = 1 .when(
and t1.employee = %(employee)s (
and CASE WHEN t2.include_holiday != 1 THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date (LeaveApplication.half_day_date == date)
WHEN t2.include_holiday THEN %(dt)s between from_date and to_date | (LeaveApplication.from_date == LeaveApplication.to_date)
END ),
""".format( LeaveApplication.half_day,
holidays )
), .else_(0)
{"employee": employee, "dt": dt}, ).as_("is_half_day")
query = (
frappe.qb.from_(LeaveApplication)
.inner_join(LeaveType)
.on((LeaveType.name == LeaveApplication.leave_type))
.select(LeaveApplication.name, is_half_day)
.where(
(LeaveType.is_lwp == 1)
& (LeaveApplication.docstatus == 1)
& (LeaveApplication.status == "Approved")
& (LeaveApplication.employee == employee)
& ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date))
)
) )
if leave:
lwp = cint(leave[0][1]) and (lwp + 0.5) or (lwp + 1) # if it's a holiday only include if leave type has "include holiday" enabled
if date in holidays:
query = query.where((LeaveType.include_holiday == "1"))
leaves = query.run(as_dict=True)
if leaves:
lwp += 0.5 if leaves[0].is_half_day else 1
return lwp return lwp

View File

@@ -3,6 +3,82 @@
import unittest import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, date_diff, get_year_ending, get_year_start, getdate
class TestEmployeeBenefitApplication(unittest.TestCase): from erpnext.hr.doctype.employee.test_employee import make_employee
pass from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
from erpnext.hr.utils import get_holiday_dates_for_employee
from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import (
calculate_lwp,
)
from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
create_payroll_period,
)
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_holiday_list,
make_leave_application,
)
from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
class TestEmployeeBenefitApplication(FrappeTestCase):
def setUp(self):
date = getdate()
make_holiday_list(from_date=get_year_start(date), to_date=get_year_ending(date))
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_employee_benefit_application(self):
payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company")
employee = make_employee("test_employee_benefits@salary.com", company="_Test Company")
first_sunday = get_first_sunday("Salary Slip Test Holiday List")
leave_application = make_leave_application(
employee,
add_days(first_sunday, 1),
add_days(first_sunday, 3),
"Leave Without Pay",
half_day=1,
half_day_date=add_days(first_sunday, 1),
submit=True,
)
frappe.db.set_value("Leave Type", "Leave Without Pay", "include_holiday", 0)
salary_structure = make_salary_structure(
"Test Employee Benefits",
"Monthly",
other_details={"max_benefits": 100000},
include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
salary_slip = make_salary_slip(salary_structure.name, employee=employee, posting_date=getdate())
salary_slip.insert()
salary_slip.submit()
application = make_employee_benefit_application(
employee, payroll_period.name, date=leave_application.to_date
)
self.assertEqual(application.employee_benefits[0].max_benefit_amount, 15000)
holidays = get_holiday_dates_for_employee(employee, payroll_period.start_date, application.date)
working_days = date_diff(application.date, payroll_period.start_date) + 1
lwp = calculate_lwp(employee, payroll_period.start_date, holidays, working_days)
self.assertEqual(lwp, 2.5)
def make_employee_benefit_application(employee, payroll_period, date):
frappe.db.delete("Employee Benefit Application")
return frappe.get_doc(
{
"doctype": "Employee Benefit Application",
"employee": employee,
"date": date,
"payroll_period": payroll_period,
"employee_benefits": [{"earning_component": "Medical Allowance", "amount": 1500}],
}
).insert()

View File

@@ -33,7 +33,9 @@ class EmployeeTaxExemptionDeclaration(Document):
self.total_declared_amount += flt(d.amount) self.total_declared_amount += flt(d.amount)
def set_total_exemption_amount(self): 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): def calculate_hra_exemption(self):
self.salary_structure_hra, self.annual_hra_exemption, self.monthly_hra_exemption = 0, 0, 0 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) hra_exemption = calculate_annual_eligible_hra_exemption(self)
if hra_exemption: if hra_exemption:
self.total_exemption_amount += hra_exemption["annual_exemption"] self.total_exemption_amount += hra_exemption["annual_exemption"]
self.salary_structure_hra = hra_exemption["hra_amount"] self.total_exemption_amount = flt(
self.annual_hra_exemption = hra_exemption["annual_exemption"] self.total_exemption_amount, self.precision("total_exemption_amount")
self.monthly_hra_exemption = hra_exemption["monthly_exemption"] )
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() @frappe.whitelist()

View File

@@ -4,25 +4,28 @@
import unittest import unittest
import frappe import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_months, getdate
import erpnext import erpnext
from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.utils import DuplicateDeclarationError from erpnext.hr.utils import DuplicateDeclarationError
class TestEmployeeTaxExemptionDeclaration(unittest.TestCase): class TestEmployeeTaxExemptionDeclaration(FrappeTestCase):
def setUp(self): def setUp(self):
make_employee("employee@taxexepmtion.com") make_employee("employee@taxexemption.com", company="_Test Company")
make_employee("employee1@taxexepmtion.com") make_employee("employee1@taxexemption.com", company="_Test Company")
create_payroll_period() create_payroll_period(company="_Test Company")
create_exemption_category() 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): def test_duplicate_category_in_declaration(self):
declaration = frappe.get_doc( declaration = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Declaration", "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(), "company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period", "payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(), "currency": erpnext.get_default_currency(),
@@ -46,7 +49,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
declaration = frappe.get_doc( declaration = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Declaration", "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(), "company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period", "payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(), "currency": erpnext.get_default_currency(),
@@ -68,7 +71,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
duplicate_declaration = frappe.get_doc( duplicate_declaration = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Declaration", "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(), "company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period", "payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(), "currency": erpnext.get_default_currency(),
@@ -83,7 +86,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
) )
self.assertRaises(DuplicateDeclarationError, duplicate_declaration.insert) self.assertRaises(DuplicateDeclarationError, duplicate_declaration.insert)
duplicate_declaration.employee = frappe.get_value( 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) self.assertTrue(duplicate_declaration.insert)
@@ -91,7 +94,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
declaration = frappe.get_doc( declaration = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Declaration", "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(), "company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period", "payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(), "currency": erpnext.get_default_currency(),
@@ -112,6 +115,298 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
self.assertEqual(declaration.total_exemption_amount, 100000) 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): def create_payroll_period(**args):
args = frappe._dict(args) args = frappe._dict(args)
@@ -163,3 +458,33 @@ def create_exemption_category():
"is_active": 1, "is_active": 1,
} }
).insert() ).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"}
)

View File

@@ -31,7 +31,9 @@ class EmployeeTaxExemptionProofSubmission(Document):
self.total_actual_amount += flt(d.amount) self.total_actual_amount += flt(d.amount)
def set_total_exemption_amount(self): 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): def calculate_hra_exemption(self):
self.monthly_hra_exemption, self.monthly_house_rent, self.total_eligible_hra_exemption = 0, 0, 0 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) hra_exemption = calculate_hra_exemption_for_period(self)
if hra_exemption: if hra_exemption:
self.exemption_amount += hra_exemption["total_eligible_hra_exemption"] self.exemption_amount += hra_exemption["total_eligible_hra_exemption"]
self.monthly_hra_exemption = hra_exemption["monthly_exemption"] self.exemption_amount = flt(self.exemption_amount, self.precision("exemption_amount"))
self.monthly_house_rent = hra_exemption["monthly_house_rent"] self.monthly_hra_exemption = flt(
self.total_eligible_hra_exemption = hra_exemption["total_eligible_hra_exemption"] 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")
)

View File

@@ -4,22 +4,26 @@
import unittest import unittest
import frappe 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 ( from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
create_exemption_category, create_exemption_category,
create_payroll_period, create_payroll_period,
setup_hra_exemption_prerequisites,
) )
class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase): class TestEmployeeTaxExemptionProofSubmission(FrappeTestCase):
def setup(self): def setUp(self):
make_employee("employee@proofsubmission.com") make_employee("employee@proofsubmission.com", company="_Test Company")
create_payroll_period() create_payroll_period(company="_Test Company")
create_exemption_category() 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): def test_exemption_amount_lesser_than_category_max(self):
declaration = frappe.get_doc( proof = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Proof Submission", "doctype": "Employee Tax Exemption Proof Submission",
"employee": frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name"), "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) self.assertRaises(frappe.ValidationError, proof.save)
declaration = frappe.get_doc( proof = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Proof Submission", "doctype": "Employee Tax Exemption Proof Submission",
"payroll_period": "Test Payroll Period", "payroll_period": "Test Payroll Period",
@@ -50,11 +54,11 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
], ],
} }
) )
self.assertTrue(declaration.save) self.assertTrue(proof.save)
self.assertTrue(declaration.submit) self.assertTrue(proof.submit)
def test_duplicate_category_in_proof_submission(self): def test_duplicate_category_in_proof_submission(self):
declaration = frappe.get_doc( proof = frappe.get_doc(
{ {
"doctype": "Employee Tax Exemption Proof Submission", "doctype": "Employee Tax Exemption Proof Submission",
"employee": frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name"), "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

View File

@@ -40,30 +40,69 @@ frappe.ui.form.on('Payroll Entry', {
}, },
refresh: function (frm) { refresh: function (frm) {
if (frm.doc.docstatus == 0) { if (frm.doc.docstatus === 0 && !frm.is_new()) {
if (!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.page.clear_primary_action();
frm.add_custom_button(__("Get Employees"), frm.page.set_primary_action(__("Create Salary Slips"), () => {
function () { frm.save("Submit").then(() => {
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.clear_primary_action(); frm.page.clear_primary_action();
frm.refresh(); frm.refresh();
frm.events.refresh(frm); 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(); if (frm.custom_buttons) frm.clear_custom_buttons();
frm.events.add_context_buttons(frm); frm.events.add_context_buttons(frm);
} }
if (frm.doc.status == "Failed" && frm.doc.error_message) {
const issue = `<a id="jump_to_error" style="text-decoration: underline;">issue</a>`;
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) { get_employee_details: function (frm) {
@@ -88,7 +127,7 @@ frappe.ui.form.on('Payroll Entry', {
doc: frm.doc, doc: frm.doc,
method: "create_salary_slips", method: "create_salary_slips",
callback: function () { callback: function () {
frm.refresh(); frm.reload_doc();
frm.toolbar.refresh(); frm.toolbar.refresh();
} }
}); });
@@ -97,7 +136,7 @@ frappe.ui.form.on('Payroll Entry', {
add_context_buttons: function (frm) { add_context_buttons: function (frm) {
if (frm.doc.salary_slips_submitted || (frm.doc.__onload && frm.doc.__onload.submitted_ss)) { if (frm.doc.salary_slips_submitted || (frm.doc.__onload && frm.doc.__onload.submitted_ss)) {
frm.events.add_bank_entry_button(frm); 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 () { frm.add_custom_button(__("Submit Salary Slip"), function () {
submit_salary_slip(frm); submit_salary_slip(frm);
}).addClass("btn-primary"); }).addClass("btn-primary");
@@ -331,6 +370,7 @@ const submit_salary_slip = function (frm) {
method: 'submit_salary_slips', method: 'submit_salary_slips',
args: {}, args: {},
callback: function () { callback: function () {
frm.reload_doc();
frm.events.refresh(frm); frm.events.refresh(frm);
}, },
doc: frm.doc, doc: frm.doc,

View File

@@ -8,11 +8,11 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"section_break0", "section_break0",
"column_break0",
"posting_date", "posting_date",
"payroll_frequency", "payroll_frequency",
"company", "company",
"column_break1", "column_break1",
"status",
"currency", "currency",
"exchange_rate", "exchange_rate",
"payroll_payable_account", "payroll_payable_account",
@@ -41,11 +41,14 @@
"cost_center", "cost_center",
"account", "account",
"payment_account", "payment_account",
"amended_from",
"column_break_33", "column_break_33",
"bank_account", "bank_account",
"salary_slips_created", "salary_slips_created",
"salary_slips_submitted" "salary_slips_submitted",
"failure_details_section",
"error_message",
"section_break_41",
"amended_from"
], ],
"fields": [ "fields": [
{ {
@@ -53,11 +56,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Select Employees" "label": "Select Employees"
}, },
{
"fieldname": "column_break0",
"fieldtype": "Column Break",
"width": "50%"
},
{ {
"default": "Today", "default": "Today",
"fieldname": "posting_date", "fieldname": "posting_date",
@@ -231,6 +229,7 @@
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1, "hidden": 1,
"label": "Salary Slips Created", "label": "Salary Slips Created",
"no_copy": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -239,6 +238,7 @@
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1, "hidden": 1,
"label": "Salary Slips Submitted", "label": "Salary Slips Submitted",
"no_copy": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -284,15 +284,44 @@
"label": "Payroll Payable Account", "label": "Payroll Payable Account",
"options": "Account", "options": "Account",
"reqd": 1 "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", "icon": "fa fa-cog",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2020-12-17 15:13:17.766210", "modified": "2022-03-16 12:45:21.662765",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Payroll", "module": "Payroll",
"name": "Payroll Entry", "name": "Payroll Entry",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -308,5 +337,6 @@
} }
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
"states": []
} }

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
import json
import frappe import frappe
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -40,8 +41,10 @@ class PayrollEntry(Document):
def validate(self): def validate(self):
self.number_of_employees = len(self.employees) self.number_of_employees = len(self.employees)
self.set_status()
def on_submit(self): def on_submit(self):
self.set_status(update=True, status="Submitted")
self.create_salary_slips() self.create_salary_slips()
def before_submit(self): def before_submit(self):
@@ -51,6 +54,15 @@ class PayrollEntry(Document):
if self.validate_employee_attendance(): if self.validate_employee_attendance():
frappe.throw(_("Cannot Submit, Employees left to mark 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): def validate_employee_details(self):
emp_with_sal_slip = [] emp_with_sal_slip = []
for employee_details in self.employees: 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_created", 0)
self.db_set("salary_slips_submitted", 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): def get_emp_list(self):
""" """
@@ -183,8 +197,20 @@ class PayrollEntry(Document):
"currency": self.currency, "currency": self.currency,
} }
) )
if len(employees) > 30: if len(employees) > 30 or frappe.flags.enqueue_payroll_entry:
frappe.enqueue(create_salary_slips_for_employees, timeout=600, employees=employees, args=args) 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: else:
create_salary_slips_for_employees(employees, args, publish_progress=False) 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 # since this method is called via frm.call this doc needs to be updated manually
@@ -214,13 +240,23 @@ class PayrollEntry(Document):
@frappe.whitelist() @frappe.whitelist()
def submit_salary_slips(self): def submit_salary_slips(self):
self.check_permission("write") self.check_permission("write")
ss_list = self.get_sal_slip_list(ss_status=0) salary_slips = self.get_sal_slip_list(ss_status=0)
if len(ss_list) > 30: if len(salary_slips) > 30 or frappe.flags.enqueue_payroll_entry:
self.db_set("status", "Queued")
frappe.enqueue( 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: 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): def email_salary_slip(self, submitted_ss):
if frappe.db.get_single_value("Payroll Settings", "email_salary_slip_to_employee"): if frappe.db.get_single_value("Payroll Settings", "email_salary_slip_to_employee"):
@@ -233,7 +269,11 @@ class PayrollEntry(Document):
) )
if not account: 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 return account
@@ -790,36 +830,80 @@ def payroll_entry_has_bank_entries(name):
return response 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): def create_salary_slips_for_employees(employees, args, publish_progress=True):
salary_slips_exists_for = get_existing_salary_slips(employees, args) try:
count = 0 payroll_entry = frappe.get_doc("Payroll Entry", args.payroll_entry)
salary_slips_not_created = [] salary_slips_exist_for = get_existing_salary_slips(employees, args)
for emp in employees: count = 0
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..."),
)
else: for emp in employees:
salary_slips_not_created.append(emp) 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) count += 1
payroll_entry.db_set("salary_slips_created", 1) if publish_progress:
payroll_entry.notify_update() 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( frappe.msgprint(
_( _(
"Salary Slips already exists for employees {}, and will not be processed by this payroll." "No salary slip found to submit for the above selected criteria OR salary slip already submitted"
).format(frappe.bold(", ".join([emp for emp in salary_slips_not_created]))), )
title=_("Message"), )
indicator="orange", 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): def submit_salary_slips_for_employees(payroll_entry, salary_slips, publish_progress=True):
submitted_ss = [] try:
not_submitted_ss = [] submitted = []
frappe.flags.via_payroll_entry = True unsubmitted = []
frappe.flags.via_payroll_entry = True
count = 0
count = 0 for entry in salary_slips:
for ss in salary_slips: salary_slip = frappe.get_doc("Salary Slip", entry[0])
ss_obj = frappe.get_doc("Salary Slip", ss[0]) if salary_slip.net_pay < 0:
if ss_obj.net_pay < 0: unsubmitted.append(entry[0])
not_submitted_ss.append(ss[0]) else:
else: try:
try: salary_slip.submit()
ss_obj.submit() submitted.append(salary_slip)
submitted_ss.append(ss_obj) except frappe.ValidationError:
except frappe.ValidationError: unsubmitted.append(entry[0])
not_submitted_ss.append(ss[0])
count += 1 count += 1
if publish_progress: if publish_progress:
frappe.publish_progress(count * 100 / len(salary_slips), title=_("Submitting Salary Slips...")) 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)
)
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) show_payroll_submission_status(submitted, unsubmitted, payroll_entry)
payroll_entry.notify_update()
if not submitted_ss and not not_submitted_ss: except Exception as e:
frappe.msgprint( frappe.db.rollback()
_( log_payroll_failure("submission", payroll_entry, e)
"No salary slip found to submit for the above selected criteria OR salary slip already submitted"
)
)
if not_submitted_ss: finally:
frappe.msgprint(_("Could not submit some Salary Slips")) frappe.db.commit() # nosemgrep
frappe.publish_realtime("completed_salary_slip_submission")
frappe.flags.via_payroll_entry = False frappe.flags.via_payroll_entry = False

View File

@@ -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];
}
};

View File

@@ -5,6 +5,7 @@ import unittest
import frappe import frappe
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_months from frappe.utils import add_months
import erpnext 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.payroll_entry.payroll_entry import get_end_date, get_start_end_dates
from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
create_account, create_account,
get_salary_component_account,
make_deduction_salary_component, make_deduction_salary_component,
make_earning_salary_component, make_earning_salary_component,
make_employee_salary_slip, set_salary_component_account,
) )
from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
create_salary_structure_assignment, create_salary_structure_assignment,
@@ -35,13 +35,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
test_dependencies = ["Holiday List"] test_dependencies = ["Holiday List"]
class TestPayrollEntry(unittest.TestCase): class TestPayrollEntry(FrappeTestCase):
@classmethod
def setUpClass(cls):
frappe.db.set_value(
"Company", erpnext.get_default_company(), "default_holiday_list", "_Test Holiday List"
)
def setUp(self): def setUp(self):
for dt in [ for dt in [
"Salary Slip", "Salary Slip",
@@ -52,81 +46,72 @@ class TestPayrollEntry(unittest.TestCase):
"Salary Structure Assignment", "Salary Structure Assignment",
"Payroll Employee Detail", "Payroll Employee Detail",
"Additional Salary", "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_earning_salary_component(setup=True, company_list=["_Test Company"])
make_deduction_salary_component(setup=True, test_tax=False, 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) frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 0)
def test_payroll_entry(self): # pylint: disable=no-self-use # set default payable account
company = erpnext.get_default_company() default_account = frappe.db.get_value(
for data in frappe.get_all("Salary Component", fields=["name"]): "Company", "_Test Company", "default_payroll_payable_account"
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,
) )
dates = get_start_end_dates("Monthly", nowdate()) if not default_account or default_account != "_Test Payroll Payable - _TC":
if not frappe.db.get_value( create_account(
"Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date} account_name="_Test Payroll Payable",
): company="_Test Company",
make_payroll_entry( parent_account="Current Liabilities - _TC",
start_date=dates.start_date, account_type="Payable",
end_date=dates.end_date, )
payable_account=company_doc.default_payroll_payable_account, frappe.db.set_value(
currency=company_doc.default_currency, "Company", "_Test Company", "default_payroll_payable_account", "_Test Payroll Payable - _TC"
) )
def test_multi_currency_payroll_entry(self): # pylint: disable=no-self-use def test_payroll_entry(self):
company = erpnext.get_default_company() company = frappe.get_doc("Company", "_Test Company")
employee = make_employee("test_muti_currency_employee@payroll.com", company=company) employee = frappe.db.get_value("Employee", {"company": "_Test Company"})
for data in frappe.get_all("Salary Component", fields=["name"]): setup_salary_structure(employee, company)
if not frappe.db.get_value(
"Salary Component Account", {"parent": data.name, "company": company}, "name"
):
get_salary_component_account(data.name)
company_doc = frappe.get_doc("Company", company) dates = get_start_end_dates("Monthly", nowdate())
salary_structure = make_salary_structure( make_payroll_entry(
"_Test Multi Currency Salary Structure", "Monthly", company=company, currency="USD" 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" def test_multi_currency_payroll_entry(self):
) company = frappe.get_doc("Company", "_Test Company")
frappe.db.sql( employee = make_employee(
"""delete from `tabSalary Slip` where employee=%s""", "test_muti_currency_employee@payroll.com", company=company.name, department="Accounts - _TC"
(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"
) )
salary_structure = "_Test Multi Currency Salary Structure"
setup_salary_structure(employee, company, "USD", salary_structure)
dates = get_start_end_dates("Monthly", nowdate()) dates = get_start_end_dates("Monthly", nowdate())
payroll_entry = make_payroll_entry( payroll_entry = make_payroll_entry(
start_date=dates.start_date, start_date=dates.start_date,
end_date=dates.end_date, end_date=dates.end_date,
payable_account=company_doc.default_payroll_payable_account, payable_account=company.default_payroll_payable_account,
currency="USD", currency="USD",
exchange_rate=70, exchange_rate=70,
company=company.name,
cost_center="Main - _TC",
) )
payroll_entry.make_payment_entry() 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 payroll_je = salary_slip.journal_entry
if payroll_je: if payroll_je:
payroll_je_doc = frappe.get_doc("Journal Entry", 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_debit)
self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_credit) self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_credit)
@@ -139,27 +124,15 @@ class TestPayrollEntry(unittest.TestCase):
(payroll_entry.name), (payroll_entry.name),
as_dict=1, 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_debit)
self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_credit) 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 def test_payroll_entry_with_employee_cost_center(self):
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)
if not frappe.db.exists("Department", "cc - _TC"): if not frappe.db.exists("Department", "cc - _TC"):
frappe.get_doc( frappe.get_doc(
{"doctype": "Department", "department_name": "cc", "company": "_Test Company"} {"doctype": "Department", "department_name": "cc", "company": "_Test Company"}
).insert() ).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( employee1 = make_employee(
"test_employee1@example.com", "test_employee1@example.com",
payroll_cost_center="_Test Cost Center - _TC", 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" "test_employee2@example.com", department="cc - _TC", company="_Test Company"
) )
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): company = frappe.get_doc("Company", "_Test Company")
create_account( setup_salary_structure(employee1, company)
account_name="_Test Payroll Payable",
company="_Test Company",
parent_account="Current Liabilities - _TC",
account_type="Payable",
)
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( ss = make_salary_structure(
"_Test Salary Structure 2", "_Test Salary Structure 2",
"Monthly", "Monthly",
employee2, employee2,
company="_Test Company", company="_Test Company",
currency=currency, currency=company.default_currency,
test_tax=False, test_tax=False,
) )
@@ -220,42 +170,38 @@ class TestPayrollEntry(unittest.TestCase):
ssa_doc.append( ssa_doc.append(
"payroll_cost_centers", {"cost_center": "_Test Cost Center 2 - _TC", "percentage": 40} "payroll_cost_centers", {"cost_center": "_Test Cost Center 2 - _TC", "percentage": 40}
) )
ssa_doc.save() ssa_doc.save()
dates = get_start_end_dates("Monthly", nowdate()) dates = get_start_end_dates("Monthly", nowdate())
if not frappe.db.get_value( pe = make_payroll_entry(
"Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date} start_date=dates.start_date,
): end_date=dates.end_date,
pe = make_payroll_entry( payable_account="_Test Payroll Payable - _TC",
start_date=dates.start_date, currency=frappe.db.get_value("Company", "_Test Company", "default_currency"),
end_date=dates.end_date, department="cc - _TC",
payable_account="_Test Payroll Payable - _TC", company="_Test Company",
currency=frappe.db.get_value("Company", "_Test Company", "default_currency"), payment_account="Cash - _TC",
department="cc - _TC", cost_center="Main - _TC",
company="_Test Company", )
payment_account="Cash - _TC", je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry")
cost_center="Main - _TC", je_entries = frappe.db.sql(
) """
je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry") select account, cost_center, debit, credit
je_entries = frappe.db.sql( from `tabJournal Entry Account`
""" where parent=%s
select account, cost_center, debit, credit order by account, cost_center
from `tabJournal Entry Account` """,
where parent=%s je,
order by account, cost_center )
""", expected_je = (
je, ("_Test Payroll Payable - _TC", "Main - _TC", 0.0, 155600.0),
) ("Salary - _TC", "_Test Cost Center - _TC", 124800.0, 0.0),
expected_je = ( ("Salary - _TC", "_Test Cost Center 2 - _TC", 31200.0, 0.0),
("_Test Payroll Payable - _TC", "Main - _TC", 0.0, 155600.0), ("Salary Deductions - _TC", "_Test Cost Center - _TC", 0.0, 320.0),
("Salary - _TC", "_Test Cost Center - _TC", 124800.0, 0.0), ("Salary Deductions - _TC", "_Test Cost Center 2 - _TC", 0.0, 80.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): def test_get_end_date(self):
self.assertEqual(get_end_date("2017-01-01", "monthly"), {"end_date": "2017-01-31"}) 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"}) self.assertEqual(get_end_date("2017-02-15", "daily"), {"end_date": "2017-02-15"})
def test_loan(self): def test_loan(self):
branch = "Test Employee Branch"
applicant = make_employee("test_employee@loan.com", company="_Test Company")
company = "_Test Company" company = "_Test Company"
holiday_list = make_holiday("test holiday for loan") branch = "Test Employee Branch"
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()
if not frappe.db.exists("Branch", branch): if not frappe.db.exists("Branch", branch):
frappe.get_doc({"doctype": "Branch", "branch": branch}).insert() frappe.get_doc({"doctype": "Branch", "branch": branch}).insert()
holiday_list = make_holiday("test holiday for loan")
employee_doc = frappe.get_doc("Employee", applicant) applicant = make_employee(
employee_doc.branch = branch "test_employee@loan.com", company="_Test Company", branch=branch, holiday_list=holiday_list
employee_doc.holiday_list = holiday_list )
employee_doc.save() company_doc = frappe.get_doc("Company", company)
salary_structure = "Test Salary Structure for Loan"
make_salary_structure( make_salary_structure(
salary_structure, "Test Salary Structure for Loan",
"Monthly", "Monthly",
employee=employee_doc.name, employee=applicant,
company="_Test Company", company="_Test Company",
currency=company_doc.default_currency, currency=company_doc.default_currency,
) )
@@ -353,11 +290,110 @@ class TestPayrollEntry(unittest.TestCase):
self.assertEqual(row.principal_amount, principal_amount) self.assertEqual(row.principal_amount, principal_amount)
self.assertEqual(row.total_payment, interest_amount + principal_amount) self.assertEqual(row.total_payment, interest_amount + principal_amount)
if salary_slip.docstatus == 0: def test_salary_slip_operation_queueing(self):
frappe.delete_doc("Salary Slip", name) 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) args = frappe._dict(args)
payroll_entry = frappe.new_doc("Payroll Entry") 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.payment_account = args.payment_account
payroll_entry.fill_employee_details() payroll_entry.fill_employee_details()
payroll_entry.save() payroll_entry.insert()
payroll_entry.create_salary_slips()
# 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() payroll_entry.submit_salary_slips()
if payroll_entry.get_sal_slip_list(ss_status=1): if payroll_entry.get_sal_slip_list(ss_status=1):
payroll_entry.make_payment_entry() payroll_entry.make_payment_entry()
@@ -423,10 +468,17 @@ def make_holiday(holiday_list_name):
return holiday_list_name return holiday_list_name
def get_salary_slip(user, period, salary_structure): def setup_salary_structure(employee, company_doc, currency=None, salary_structure=None):
salary_slip = make_employee_salary_slip(user, period, salary_structure) for data in frappe.get_all("Salary Component", pluck="name"):
salary_slip.exchange_rate = 70 if not frappe.db.get_value(
salary_slip.calculate_net_pay() "Salary Component Account", {"parent": data, "company": company_doc.name}, "name"
salary_slip.db_update() ):
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),
)

View File

@@ -29,6 +29,9 @@ from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
calculate_amounts, calculate_amounts,
create_repayment_entry, 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.additional_salary.additional_salary import get_additional_salaries
from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import ( from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import (
get_benefit_component_amount, get_benefit_component_amount,
@@ -462,37 +465,14 @@ class SalarySlip(TransactionBase):
) )
for d in range(working_days): for d in range(working_days):
dt = add_days(cstr(getdate(self.start_date)), d) date = add_days(cstr(getdate(self.start_date)), d)
leave = frappe.db.sql( leave = get_lwp_or_ppl_for_date(date, self.employee, holidays)
"""
SELECT t1.name,
CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date)
THEN t1.half_day else 0 END,
t2.is_ppl,
t2.fraction_of_daily_salary_per_leave
FROM `tabLeave Application` t1, `tabLeave Type` t2
WHERE t2.name = t1.leave_type
AND (t2.is_lwp = 1 or t2.is_ppl = 1)
AND t1.docstatus = 1
AND t1.employee = %(employee)s
AND ifnull(t1.salary_slip, '') = ''
AND CASE
WHEN t2.include_holiday != 1
THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date
WHEN t2.include_holiday
THEN %(dt)s between from_date and to_date
END
""".format(
holidays
),
{"employee": self.employee, "dt": dt},
)
if leave: if leave:
equivalent_lwp_count = 0 equivalent_lwp_count = 0
is_half_day_leave = cint(leave[0][1]) is_half_day_leave = cint(leave[0].is_half_day)
is_partially_paid_leave = cint(leave[0][2]) is_partially_paid_leave = cint(leave[0].is_ppl)
fraction_of_daily_salary_per_leave = flt(leave[0][3]) fraction_of_daily_salary_per_leave = flt(leave[0].fraction_of_daily_salary_per_leave)
equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1 equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1
@@ -1364,9 +1344,9 @@ class SalarySlip(TransactionBase):
self.total_loan_repayment += payment.total_payment self.total_loan_repayment += payment.total_payment
def get_loan_details(self): def get_loan_details(self):
return frappe.get_all( loan_details = frappe.get_all(
"Loan", "Loan",
fields=["name", "interest_income_account", "loan_account", "loan_type"], fields=["name", "interest_income_account", "loan_account", "loan_type", "is_term_loan"],
filters={ filters={
"applicant": self.employee, "applicant": self.employee,
"docstatus": 1, "docstatus": 1,
@@ -1375,6 +1355,15 @@ class SalarySlip(TransactionBase):
}, },
) )
if loan_details:
for loan in loan_details:
if loan.is_term_loan:
process_loan_interest_accrual_for_term_loans(
posting_date=self.posting_date, loan_type=loan.loan_type, loan=loan.name
)
return loan_details
def make_loan_repayment_entry(self): def make_loan_repayment_entry(self):
payroll_payable_account = get_payroll_payable_account(self.company, self.payroll_entry) payroll_payable_account = get_payroll_payable_account(self.company, self.payroll_entry)
for loan in self.loans: for loan in self.loans:
@@ -1730,3 +1719,46 @@ def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
except Exception as e: except Exception as e:
frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e)) frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e))
raise raise
def get_lwp_or_ppl_for_date(date, employee, holidays):
LeaveApplication = frappe.qb.DocType("Leave Application")
LeaveType = frappe.qb.DocType("Leave Type")
is_half_day = (
frappe.qb.terms.Case()
.when(
(
(LeaveApplication.half_day_date == date)
| (LeaveApplication.from_date == LeaveApplication.to_date)
),
LeaveApplication.half_day,
)
.else_(0)
).as_("is_half_day")
query = (
frappe.qb.from_(LeaveApplication)
.inner_join(LeaveType)
.on((LeaveType.name == LeaveApplication.leave_type))
.select(
LeaveApplication.name,
LeaveType.is_ppl,
LeaveType.fraction_of_daily_salary_per_leave,
(is_half_day),
)
.where(
(((LeaveType.is_lwp == 1) | (LeaveType.is_ppl == 1)))
& (LeaveApplication.docstatus == 1)
& (LeaveApplication.status == "Approved")
& (LeaveApplication.employee == employee)
& ((LeaveApplication.salary_slip.isnull()) | (LeaveApplication.salary_slip == ""))
& ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date))
)
)
# if it's a holiday only include if leave type has "include holiday" enabled
if date in holidays:
query = query.where((LeaveType.include_holiday == "1"))
return query.run(as_dict=True)

View File

@@ -49,7 +49,7 @@ class TestSalarySlip(unittest.TestCase):
"Payroll Settings", {"payroll_based_on": "Attendance", "daily_wages_fraction_for_half_day": 0.75} "Payroll Settings", {"payroll_based_on": "Attendance", "daily_wages_fraction_for_half_day": 0.75}
) )
def test_payment_days_based_on_attendance(self): def test_payment_days_based_on_attendance(self):
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
emp_id = make_employee("test_payment_days_based_on_attendance@salary.com") emp_id = make_employee("test_payment_days_based_on_attendance@salary.com")
frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
@@ -128,7 +128,7 @@ class TestSalarySlip(unittest.TestCase):
}, },
) )
def test_payment_days_for_mid_joinee_including_holidays(self): def test_payment_days_for_mid_joinee_including_holidays(self):
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -196,7 +196,7 @@ class TestSalarySlip(unittest.TestCase):
# tests mid month joining and relieving along with unmarked days # tests mid month joining and relieving along with unmarked days
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -236,7 +236,7 @@ class TestSalarySlip(unittest.TestCase):
def test_payment_days_for_mid_joinee_excluding_holidays(self): def test_payment_days_for_mid_joinee_excluding_holidays(self):
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -267,7 +267,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"payroll_based_on": "Leave"}) @change_settings("Payroll Settings", {"payroll_based_on": "Leave"})
def test_payment_days_based_on_leave_application(self): def test_payment_days_based_on_leave_application(self):
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com") emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com")
frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
@@ -366,7 +366,7 @@ class TestSalarySlip(unittest.TestCase):
salary_slip.submit() salary_slip.submit()
salary_slip.reload() salary_slip.reload()
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
days_in_month = no_of_days[0] days_in_month = no_of_days[0]
no_of_holidays = no_of_days[1] no_of_holidays = no_of_days[1]
@@ -441,7 +441,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 1}) @change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 1})
def test_salary_slip_with_holidays_included(self): def test_salary_slip_with_holidays_included(self):
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
make_employee("test_salary_slip_with_holidays_included@salary.com") make_employee("test_salary_slip_with_holidays_included@salary.com")
frappe.db.set_value( frappe.db.set_value(
"Employee", "Employee",
@@ -473,7 +473,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 0}) @change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 0})
def test_salary_slip_with_holidays_excluded(self): def test_salary_slip_with_holidays_excluded(self):
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
make_employee("test_salary_slip_with_holidays_excluded@salary.com") make_employee("test_salary_slip_with_holidays_excluded@salary.com")
frappe.db.set_value( frappe.db.set_value(
"Employee", "Employee",
@@ -510,7 +510,7 @@ class TestSalarySlip(unittest.TestCase):
create_salary_structure_assignment, create_salary_structure_assignment,
) )
no_of_days = self.get_no_of_days() no_of_days = get_no_of_days()
# set joinng date in the same month # set joinng date in the same month
employee = make_employee("test_payment_days@salary.com") employee = make_employee("test_payment_days@salary.com")
@@ -984,17 +984,18 @@ class TestSalarySlip(unittest.TestCase):
activity_type.wage_rate = 25 activity_type.wage_rate = 25
activity_type.save() activity_type.save()
def get_no_of_days(self):
no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month)
no_of_holidays_in_month = len(
[
1
for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month)
if i[6] != 0
]
)
return [no_of_days_in_month[1], no_of_holidays_in_month] def get_no_of_days():
no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month)
no_of_holidays_in_month = len(
[
1
for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month)
if i[6] != 0
]
)
return [no_of_days_in_month[1], no_of_holidays_in_month]
def make_employee_salary_slip(user, payroll_frequency, salary_structure=None, posting_date=None): def make_employee_salary_slip(user, payroll_frequency, salary_structure=None, posting_date=None):
@@ -1050,10 +1051,10 @@ def make_salary_component(salary_components, test_tax, company_list=None):
doc.update(salary_component) doc.update(salary_component)
doc.insert() 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() company = erpnext.get_default_company()
if company_list and company not in company_list: if company_list and company not in company_list:
@@ -1136,6 +1137,7 @@ def make_earning_salary_component(
"pay_against_benefit_claim": 0, "pay_against_benefit_claim": 0,
"type": "Earning", "type": "Earning",
"max_benefit_amount": 15000, "max_benefit_amount": 15000,
"depends_on_payment_days": 1,
}, },
] ]
) )

View File

@@ -253,6 +253,7 @@ def make_salary_slip(
source_name, source_name,
target_doc=None, target_doc=None,
employee=None, employee=None,
posting_date=None,
as_print=False, as_print=False,
print_format=None, print_format=None,
for_preview=0, for_preview=0,
@@ -269,6 +270,9 @@ def make_salary_slip(
target.designation = employee_details.designation target.designation = employee_details.designation
target.department = employee_details.department target.department = employee_details.department
if posting_date:
target.posting_date = posting_date
target.run_method("process_salary_structure", for_preview=for_preview) target.run_method("process_salary_structure", for_preview=for_preview)
doc = get_mapped_doc( doc = get_mapped_doc(

View File

@@ -169,9 +169,6 @@ def make_salary_structure(
payroll_period=None, payroll_period=None,
include_flexi_benefits=False, 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): if frappe.db.exists("Salary Structure", salary_structure):
frappe.db.delete("Salary Structure", salary_structure) frappe.db.delete("Salary Structure", salary_structure)
@@ -230,9 +227,12 @@ def create_salary_structure_assignment(
company=None, company=None,
currency=erpnext.get_default_currency(), currency=erpnext.get_default_currency(),
payroll_period=None, payroll_period=None,
base=None,
allow_duplicate=False,
): ):
if not allow_duplicate and frappe.db.exists(
if frappe.db.exists("Salary Structure Assignment", {"employee": employee}): "Salary Structure Assignment", {"employee": employee}
):
frappe.db.sql("""delete from `tabSalary Structure Assignment` where employee=%s""", (employee)) frappe.db.sql("""delete from `tabSalary Structure Assignment` where employee=%s""", (employee))
if not payroll_period: 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 = frappe.new_doc("Salary Structure Assignment")
salary_structure_assignment.employee = employee salary_structure_assignment.employee = employee
salary_structure_assignment.base = 50000 salary_structure_assignment.base = base or 50000
salary_structure_assignment.variable = 5000 salary_structure_assignment.variable = 5000
if not from_date: if not from_date:

Some files were not shown because too many files have changed in this diff Show More