diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ad1ba2a1573..8594ebbe9d5 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -372,3 +372,4 @@ erpnext.patches.v14_0.delete_employee_transfer_property_doctype erpnext.patches.v13_0.create_accounting_dimensions_in_orders erpnext.patches.v13_0.set_per_billed_in_return_delivery_note execute:frappe.delete_doc("DocType", "Naming Series") +erpnext.patches.v13_0.set_payroll_entry_status diff --git a/erpnext/patches/v13_0/set_payroll_entry_status.py b/erpnext/patches/v13_0/set_payroll_entry_status.py new file mode 100644 index 00000000000..97adff9295f --- /dev/null +++ b/erpnext/patches/v13_0/set_payroll_entry_status.py @@ -0,0 +1,16 @@ +import frappe +from frappe.query_builder import Case + + +def execute(): + PayrollEntry = frappe.qb.DocType("Payroll Entry") + + ( + frappe.qb.update(PayrollEntry).set( + "status", + Case() + .when(PayrollEntry.docstatus == 0, "Draft") + .when(PayrollEntry.docstatus == 1, "Submitted") + .else_("Cancelled"), + ) + ).run() diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js index 62e183e59c7..b06f3502e26 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js @@ -40,30 +40,69 @@ frappe.ui.form.on('Payroll Entry', { }, refresh: function (frm) { - if (frm.doc.docstatus == 0) { - if (!frm.is_new()) { + if (frm.doc.docstatus === 0 && !frm.is_new()) { + frm.page.clear_primary_action(); + frm.add_custom_button(__("Get Employees"), + function () { + frm.events.get_employee_details(frm); + } + ).toggleClass("btn-primary", !(frm.doc.employees || []).length); + } + + if ( + (frm.doc.employees || []).length + && !frappe.model.has_workflow(frm.doctype) + && !cint(frm.doc.salary_slips_created) + && (frm.doc.docstatus != 2) + ) { + if (frm.doc.docstatus == 0) { frm.page.clear_primary_action(); - frm.add_custom_button(__("Get Employees"), - function () { - frm.events.get_employee_details(frm); - } - ).toggleClass('btn-primary', !(frm.doc.employees || []).length); - } - if ((frm.doc.employees || []).length && !frappe.model.has_workflow(frm.doctype)) { - frm.page.clear_primary_action(); - frm.page.set_primary_action(__('Create Salary Slips'), () => { - frm.save('Submit').then(() => { + frm.page.set_primary_action(__("Create Salary Slips"), () => { + frm.save("Submit").then(() => { frm.page.clear_primary_action(); frm.refresh(); frm.events.refresh(frm); }); }); + } else if (frm.doc.docstatus == 1 && frm.doc.status == "Failed") { + frm.add_custom_button(__("Create Salary Slip"), function () { + frm.call("create_salary_slips", {}, () => { + frm.reload_doc(); + }); + }).addClass("btn-primary"); } } - if (frm.doc.docstatus == 1) { + + if (frm.doc.docstatus == 1 && frm.doc.status == "Submitted") { if (frm.custom_buttons) frm.clear_custom_buttons(); frm.events.add_context_buttons(frm); } + + if (frm.doc.status == "Failed" && frm.doc.error_message) { + const issue = `issue`; + let process = (cint(frm.doc.salary_slips_created)) ? "submission" : "creation"; + + frm.dashboard.set_headline( + __("Salary Slip {0} failed. You can resolve the {1} and retry {0}.", [process, issue]) + ); + + $("#jump_to_error").on("click", (e) => { + e.preventDefault(); + frappe.utils.scroll_to( + frm.get_field("error_message").$wrapper, + true, + 30 + ); + }); + } + + frappe.realtime.on("completed_salary_slip_creation", function() { + frm.reload_doc(); + }); + + frappe.realtime.on("completed_salary_slip_submission", function() { + frm.reload_doc(); + }); }, get_employee_details: function (frm) { @@ -88,7 +127,7 @@ frappe.ui.form.on('Payroll Entry', { doc: frm.doc, method: "create_salary_slips", callback: function () { - frm.refresh(); + frm.reload_doc(); frm.toolbar.refresh(); } }); @@ -97,7 +136,7 @@ frappe.ui.form.on('Payroll Entry', { add_context_buttons: function (frm) { if (frm.doc.salary_slips_submitted || (frm.doc.__onload && frm.doc.__onload.submitted_ss)) { frm.events.add_bank_entry_button(frm); - } else if (frm.doc.salary_slips_created) { + } else if (frm.doc.salary_slips_created && frm.doc.status != 'Queued') { frm.add_custom_button(__("Submit Salary Slip"), function () { submit_salary_slip(frm); }).addClass("btn-primary"); @@ -331,6 +370,7 @@ const submit_salary_slip = function (frm) { method: 'submit_salary_slips', args: {}, callback: function () { + frm.reload_doc(); frm.events.refresh(frm); }, doc: frm.doc, diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.json b/erpnext/payroll/doctype/payroll_entry/payroll_entry.json index 0444134aa4d..17882eb5d94 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.json +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.json @@ -8,11 +8,11 @@ "engine": "InnoDB", "field_order": [ "section_break0", - "column_break0", "posting_date", "payroll_frequency", "company", "column_break1", + "status", "currency", "exchange_rate", "payroll_payable_account", @@ -41,11 +41,14 @@ "cost_center", "account", "payment_account", - "amended_from", "column_break_33", "bank_account", "salary_slips_created", - "salary_slips_submitted" + "salary_slips_submitted", + "failure_details_section", + "error_message", + "section_break_41", + "amended_from" ], "fields": [ { @@ -53,11 +56,6 @@ "fieldtype": "Section Break", "label": "Select Employees" }, - { - "fieldname": "column_break0", - "fieldtype": "Column Break", - "width": "50%" - }, { "default": "Today", "fieldname": "posting_date", @@ -231,6 +229,7 @@ "fieldtype": "Check", "hidden": 1, "label": "Salary Slips Created", + "no_copy": 1, "read_only": 1 }, { @@ -239,6 +238,7 @@ "fieldtype": "Check", "hidden": 1, "label": "Salary Slips Submitted", + "no_copy": 1, "read_only": 1 }, { @@ -284,15 +284,44 @@ "label": "Payroll Payable Account", "options": "Account", "reqd": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "error_message", + "depends_on": "eval:doc.status=='Failed';", + "fieldname": "failure_details_section", + "fieldtype": "Section Break", + "label": "Failure Details" + }, + { + "depends_on": "eval:doc.status=='Failed';", + "fieldname": "error_message", + "fieldtype": "Small Text", + "label": "Error Message", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "section_break_41", + "fieldtype": "Section Break" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Draft\nSubmitted\nCancelled\nQueued\nFailed", + "print_hide": 1, + "read_only": 1 } ], "icon": "fa fa-cog", "is_submittable": 1, "links": [], - "modified": "2020-12-17 15:13:17.766210", + "modified": "2022-03-16 12:45:21.662765", "modified_by": "Administrator", "module": "Payroll", "name": "Payroll Entry", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -308,5 +337,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 473fb0d7c76..620fcadceb2 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -1,6 +1,7 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import json import frappe from dateutil.relativedelta import relativedelta @@ -40,8 +41,10 @@ class PayrollEntry(Document): def validate(self): self.number_of_employees = len(self.employees) + self.set_status() def on_submit(self): + self.set_status(update=True, status="Submitted") self.create_salary_slips() def before_submit(self): @@ -51,6 +54,15 @@ class PayrollEntry(Document): if self.validate_employee_attendance(): frappe.throw(_("Cannot Submit, Employees left to mark attendance")) + def set_status(self, status=None, update=False): + if not status: + status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] + + if update: + self.db_set("status", status) + else: + self.status = status + def validate_employee_details(self): emp_with_sal_slip = [] for employee_details in self.employees: @@ -87,6 +99,8 @@ class PayrollEntry(Document): ) self.db_set("salary_slips_created", 0) self.db_set("salary_slips_submitted", 0) + self.set_status(update=True, status="Cancelled") + self.db_set("error_message", "") def get_emp_list(self): """ @@ -183,8 +197,20 @@ class PayrollEntry(Document): "currency": self.currency, } ) - if len(employees) > 30: - frappe.enqueue(create_salary_slips_for_employees, timeout=600, employees=employees, args=args) + if len(employees) > 30 or frappe.flags.enqueue_payroll_entry: + self.db_set("status", "Queued") + frappe.enqueue( + create_salary_slips_for_employees, + timeout=600, + employees=employees, + args=args, + publish_progress=False, + ) + frappe.msgprint( + _("Salary Slip creation is queued. It may take a few minutes"), + alert=True, + indicator="blue", + ) else: create_salary_slips_for_employees(employees, args, publish_progress=False) # since this method is called via frm.call this doc needs to be updated manually @@ -214,13 +240,23 @@ class PayrollEntry(Document): @frappe.whitelist() def submit_salary_slips(self): self.check_permission("write") - ss_list = self.get_sal_slip_list(ss_status=0) - if len(ss_list) > 30: + salary_slips = self.get_sal_slip_list(ss_status=0) + if len(salary_slips) > 30 or frappe.flags.enqueue_payroll_entry: + self.db_set("status", "Queued") frappe.enqueue( - submit_salary_slips_for_employees, timeout=600, payroll_entry=self, salary_slips=ss_list + submit_salary_slips_for_employees, + timeout=600, + payroll_entry=self, + salary_slips=salary_slips, + publish_progress=False, + ) + frappe.msgprint( + _("Salary Slip submission is queued. It may take a few minutes"), + alert=True, + indicator="blue", ) else: - submit_salary_slips_for_employees(self, ss_list, publish_progress=False) + submit_salary_slips_for_employees(self, salary_slips, publish_progress=False) def email_salary_slip(self, submitted_ss): if frappe.db.get_single_value("Payroll Settings", "email_salary_slip_to_employee"): @@ -233,7 +269,11 @@ class PayrollEntry(Document): ) if not account: - frappe.throw(_("Please set account in Salary Component {0}").format(salary_component)) + frappe.throw( + _("Please set account in Salary Component {0}").format( + get_link_to_form("Salary Component", salary_component) + ) + ) return account @@ -790,36 +830,80 @@ def payroll_entry_has_bank_entries(name): return response +def log_payroll_failure(process, payroll_entry, error): + error_log = frappe.log_error( + title=_("Salary Slip {0} failed for Payroll Entry {1}").format(process, payroll_entry.name) + ) + message_log = frappe.message_log.pop() if frappe.message_log else str(error) + + try: + error_message = json.loads(message_log).get("message") + except Exception: + error_message = message_log + + error_message += "\n" + _("Check Error Log {0} for more details.").format( + get_link_to_form("Error Log", error_log.name) + ) + + payroll_entry.db_set({"error_message": error_message, "status": "Failed"}) + + def create_salary_slips_for_employees(employees, args, publish_progress=True): - salary_slips_exists_for = get_existing_salary_slips(employees, args) - count = 0 - salary_slips_not_created = [] - for emp in employees: - if emp not in salary_slips_exists_for: - args.update({"doctype": "Salary Slip", "employee": emp}) - ss = frappe.get_doc(args) - ss.insert() - count += 1 - if publish_progress: - frappe.publish_progress( - count * 100 / len(set(employees) - set(salary_slips_exists_for)), - title=_("Creating Salary Slips..."), - ) + try: + payroll_entry = frappe.get_doc("Payroll Entry", args.payroll_entry) + salary_slips_exist_for = get_existing_salary_slips(employees, args) + count = 0 - else: - salary_slips_not_created.append(emp) + for emp in employees: + if emp not in salary_slips_exist_for: + args.update({"doctype": "Salary Slip", "employee": emp}) + frappe.get_doc(args).insert() - payroll_entry = frappe.get_doc("Payroll Entry", args.payroll_entry) - payroll_entry.db_set("salary_slips_created", 1) - payroll_entry.notify_update() + count += 1 + if publish_progress: + frappe.publish_progress( + count * 100 / len(set(employees) - set(salary_slips_exist_for)), + title=_("Creating Salary Slips..."), + ) - if salary_slips_not_created: + payroll_entry.db_set({"status": "Submitted", "salary_slips_created": 1, "error_message": ""}) + + if salary_slips_exist_for: + frappe.msgprint( + _( + "Salary Slips already exist for employees {}, and will not be processed by this payroll." + ).format(frappe.bold(", ".join(emp for emp in salary_slips_exist_for))), + title=_("Message"), + indicator="orange", + ) + + except Exception as e: + frappe.db.rollback() + log_payroll_failure("creation", payroll_entry, e) + + finally: + frappe.db.commit() # nosemgrep + frappe.publish_realtime("completed_salary_slip_creation") + + +def show_payroll_submission_status(submitted, unsubmitted, payroll_entry): + if not submitted and not unsubmitted: frappe.msgprint( _( - "Salary Slips already exists for employees {}, and will not be processed by this payroll." - ).format(frappe.bold(", ".join([emp for emp in salary_slips_not_created]))), - title=_("Message"), - indicator="orange", + "No salary slip found to submit for the above selected criteria OR salary slip already submitted" + ) + ) + elif submitted and not unsubmitted: + frappe.msgprint( + _("Salary Slips submitted for period from {0} to {1}").format( + payroll_entry.start_date, payroll_entry.end_date + ) + ) + elif unsubmitted: + frappe.msgprint( + _("Could not submit some Salary Slips: {}").format( + ", ".join(get_link_to_form("Salary Slip", entry) for entry in unsubmitted) + ) ) @@ -837,45 +921,41 @@ def get_existing_salary_slips(employees, args): def submit_salary_slips_for_employees(payroll_entry, salary_slips, publish_progress=True): - submitted_ss = [] - not_submitted_ss = [] - frappe.flags.via_payroll_entry = True + try: + submitted = [] + unsubmitted = [] + frappe.flags.via_payroll_entry = True + count = 0 - count = 0 - for ss in salary_slips: - ss_obj = frappe.get_doc("Salary Slip", ss[0]) - if ss_obj.net_pay < 0: - not_submitted_ss.append(ss[0]) - else: - try: - ss_obj.submit() - submitted_ss.append(ss_obj) - except frappe.ValidationError: - not_submitted_ss.append(ss[0]) + for entry in salary_slips: + salary_slip = frappe.get_doc("Salary Slip", entry[0]) + if salary_slip.net_pay < 0: + unsubmitted.append(entry[0]) + else: + try: + salary_slip.submit() + submitted.append(salary_slip) + except frappe.ValidationError: + unsubmitted.append(entry[0]) - count += 1 - if publish_progress: - frappe.publish_progress(count * 100 / len(salary_slips), title=_("Submitting Salary Slips...")) - if submitted_ss: - payroll_entry.make_accrual_jv_entry() - frappe.msgprint( - _("Salary Slip submitted for period from {0} to {1}").format(ss_obj.start_date, ss_obj.end_date) - ) + count += 1 + if publish_progress: + frappe.publish_progress(count * 100 / len(salary_slips), title=_("Submitting Salary Slips...")) - payroll_entry.email_salary_slip(submitted_ss) + if submitted: + payroll_entry.make_accrual_jv_entry() + payroll_entry.email_salary_slip(submitted) + payroll_entry.db_set({"salary_slips_submitted": 1, "status": "Submitted", "error_message": ""}) - payroll_entry.db_set("salary_slips_submitted", 1) - payroll_entry.notify_update() + show_payroll_submission_status(submitted, unsubmitted, payroll_entry) - if not submitted_ss and not not_submitted_ss: - frappe.msgprint( - _( - "No salary slip found to submit for the above selected criteria OR salary slip already submitted" - ) - ) + except Exception as e: + frappe.db.rollback() + log_payroll_failure("submission", payroll_entry, e) - if not_submitted_ss: - frappe.msgprint(_("Could not submit some Salary Slips")) + finally: + frappe.db.commit() # nosemgrep + frappe.publish_realtime("completed_salary_slip_submission") frappe.flags.via_payroll_entry = False diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry_list.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry_list.js new file mode 100644 index 00000000000..56390b79d8b --- /dev/null +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry_list.js @@ -0,0 +1,18 @@ +// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +// render +frappe.listview_settings['Payroll Entry'] = { + has_indicator_for_draft: 1, + get_indicator: function(doc) { + var status_color = { + 'Draft': 'red', + 'Submitted': 'blue', + 'Queued': 'orange', + 'Failed': 'red', + 'Cancelled': 'red' + + }; + return [__(doc.status), status_color[doc.status], 'status,=,'+doc.status]; + } +}; diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index fda0fcf8bee..0363a0c3de6 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -5,6 +5,7 @@ import unittest import frappe from dateutil.relativedelta import relativedelta +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_months import erpnext @@ -22,10 +23,9 @@ from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_ from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_end_date, get_start_end_dates from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( create_account, - get_salary_component_account, make_deduction_salary_component, make_earning_salary_component, - make_employee_salary_slip, + set_salary_component_account, ) from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( create_salary_structure_assignment, @@ -35,13 +35,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( test_dependencies = ["Holiday List"] -class TestPayrollEntry(unittest.TestCase): - @classmethod - def setUpClass(cls): - frappe.db.set_value( - "Company", erpnext.get_default_company(), "default_holiday_list", "_Test Holiday List" - ) - +class TestPayrollEntry(FrappeTestCase): def setUp(self): for dt in [ "Salary Slip", @@ -52,81 +46,72 @@ class TestPayrollEntry(unittest.TestCase): "Salary Structure Assignment", "Payroll Employee Detail", "Additional Salary", + "Loan", ]: - frappe.db.sql("delete from `tab%s`" % dt) + frappe.db.delete(dt) make_earning_salary_component(setup=True, company_list=["_Test Company"]) make_deduction_salary_component(setup=True, test_tax=False, company_list=["_Test Company"]) + frappe.db.set_value("Company", "_Test Company", "default_holiday_list", "_Test Holiday List") frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 0) - def test_payroll_entry(self): # pylint: disable=no-self-use - company = erpnext.get_default_company() - for data in frappe.get_all("Salary Component", fields=["name"]): - if not frappe.db.get_value( - "Salary Component Account", {"parent": data.name, "company": company}, "name" - ): - get_salary_component_account(data.name) - - employee = frappe.db.get_value("Employee", {"company": company}) - company_doc = frappe.get_doc("Company", company) - make_salary_structure( - "_Test Salary Structure", - "Monthly", - employee, - company=company, - currency=company_doc.default_currency, + # set default payable account + default_account = frappe.db.get_value( + "Company", "_Test Company", "default_payroll_payable_account" ) - dates = get_start_end_dates("Monthly", nowdate()) - if not frappe.db.get_value( - "Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date} - ): - make_payroll_entry( - start_date=dates.start_date, - end_date=dates.end_date, - payable_account=company_doc.default_payroll_payable_account, - currency=company_doc.default_currency, + if not default_account or default_account != "_Test Payroll Payable - _TC": + create_account( + account_name="_Test Payroll Payable", + company="_Test Company", + parent_account="Current Liabilities - _TC", + account_type="Payable", + ) + frappe.db.set_value( + "Company", "_Test Company", "default_payroll_payable_account", "_Test Payroll Payable - _TC" ) - def test_multi_currency_payroll_entry(self): # pylint: disable=no-self-use - company = erpnext.get_default_company() - employee = make_employee("test_muti_currency_employee@payroll.com", company=company) - for data in frappe.get_all("Salary Component", fields=["name"]): - if not frappe.db.get_value( - "Salary Component Account", {"parent": data.name, "company": company}, "name" - ): - get_salary_component_account(data.name) + def test_payroll_entry(self): + company = frappe.get_doc("Company", "_Test Company") + employee = frappe.db.get_value("Employee", {"company": "_Test Company"}) + setup_salary_structure(employee, company) - company_doc = frappe.get_doc("Company", company) - salary_structure = make_salary_structure( - "_Test Multi Currency Salary Structure", "Monthly", company=company, currency="USD" + dates = get_start_end_dates("Monthly", nowdate()) + make_payroll_entry( + start_date=dates.start_date, + end_date=dates.end_date, + payable_account=company.default_payroll_payable_account, + currency=company.default_currency, + company=company.name, ) - create_salary_structure_assignment( - employee, salary_structure.name, company=company, currency="USD" - ) - frappe.db.sql( - """delete from `tabSalary Slip` where employee=%s""", - (frappe.db.get_value("Employee", {"user_id": "test_muti_currency_employee@payroll.com"})), - ) - salary_slip = get_salary_slip( - "test_muti_currency_employee@payroll.com", "Monthly", "_Test Multi Currency Salary Structure" + + def test_multi_currency_payroll_entry(self): + company = frappe.get_doc("Company", "_Test Company") + employee = make_employee( + "test_muti_currency_employee@payroll.com", company=company.name, department="Accounts - _TC" ) + salary_structure = "_Test Multi Currency Salary Structure" + setup_salary_structure(employee, company, "USD", salary_structure) + dates = get_start_end_dates("Monthly", nowdate()) payroll_entry = make_payroll_entry( start_date=dates.start_date, end_date=dates.end_date, - payable_account=company_doc.default_payroll_payable_account, + payable_account=company.default_payroll_payable_account, currency="USD", exchange_rate=70, + company=company.name, + cost_center="Main - _TC", ) payroll_entry.make_payment_entry() - salary_slip.load_from_db() + salary_slip = frappe.db.get_value("Salary Slip", {"payroll_entry": payroll_entry.name}, "name") + salary_slip = frappe.get_doc("Salary Slip", salary_slip) + payroll_entry.reload() payroll_je = salary_slip.journal_entry if payroll_je: payroll_je_doc = frappe.get_doc("Journal Entry", payroll_je) - self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_debit) self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_credit) @@ -139,27 +124,15 @@ class TestPayrollEntry(unittest.TestCase): (payroll_entry.name), as_dict=1, ) - self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_debit) self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_credit) - def test_payroll_entry_with_employee_cost_center(self): # pylint: disable=no-self-use - for data in frappe.get_all("Salary Component", fields=["name"]): - if not frappe.db.get_value( - "Salary Component Account", {"parent": data.name, "company": "_Test Company"}, "name" - ): - get_salary_component_account(data.name) - + def test_payroll_entry_with_employee_cost_center(self): if not frappe.db.exists("Department", "cc - _TC"): frappe.get_doc( {"doctype": "Department", "department_name": "cc", "company": "_Test Company"} ).insert() - frappe.db.sql("""delete from `tabEmployee` where employee_name='test_employee1@example.com' """) - frappe.db.sql("""delete from `tabEmployee` where employee_name='test_employee2@example.com' """) - frappe.db.sql("""delete from `tabSalary Structure` where name='_Test Salary Structure 1' """) - frappe.db.sql("""delete from `tabSalary Structure` where name='_Test Salary Structure 2' """) - employee1 = make_employee( "test_employee1@example.com", payroll_cost_center="_Test Cost Center - _TC", @@ -170,38 +143,15 @@ class TestPayrollEntry(unittest.TestCase): "test_employee2@example.com", department="cc - _TC", company="_Test Company" ) - if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): - create_account( - account_name="_Test Payroll Payable", - company="_Test Company", - parent_account="Current Liabilities - _TC", - account_type="Payable", - ) + company = frappe.get_doc("Company", "_Test Company") + setup_salary_structure(employee1, company) - if ( - not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") - or frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") - != "_Test Payroll Payable - _TC" - ): - frappe.db.set_value( - "Company", "_Test Company", "default_payroll_payable_account", "_Test Payroll Payable - _TC" - ) - currency = frappe.db.get_value("Company", "_Test Company", "default_currency") - - make_salary_structure( - "_Test Salary Structure 1", - "Monthly", - employee1, - company="_Test Company", - currency=currency, - test_tax=False, - ) ss = make_salary_structure( "_Test Salary Structure 2", "Monthly", employee2, company="_Test Company", - currency=currency, + currency=company.default_currency, test_tax=False, ) @@ -220,42 +170,38 @@ class TestPayrollEntry(unittest.TestCase): ssa_doc.append( "payroll_cost_centers", {"cost_center": "_Test Cost Center 2 - _TC", "percentage": 40} ) - ssa_doc.save() dates = get_start_end_dates("Monthly", nowdate()) - if not frappe.db.get_value( - "Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date} - ): - pe = make_payroll_entry( - start_date=dates.start_date, - end_date=dates.end_date, - payable_account="_Test Payroll Payable - _TC", - currency=frappe.db.get_value("Company", "_Test Company", "default_currency"), - department="cc - _TC", - company="_Test Company", - payment_account="Cash - _TC", - cost_center="Main - _TC", - ) - je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry") - je_entries = frappe.db.sql( - """ - select account, cost_center, debit, credit - from `tabJournal Entry Account` - where parent=%s - order by account, cost_center - """, - je, - ) - expected_je = ( - ("_Test Payroll Payable - _TC", "Main - _TC", 0.0, 155600.0), - ("Salary - _TC", "_Test Cost Center - _TC", 124800.0, 0.0), - ("Salary - _TC", "_Test Cost Center 2 - _TC", 31200.0, 0.0), - ("Salary Deductions - _TC", "_Test Cost Center - _TC", 0.0, 320.0), - ("Salary Deductions - _TC", "_Test Cost Center 2 - _TC", 0.0, 80.0), - ) + pe = make_payroll_entry( + start_date=dates.start_date, + end_date=dates.end_date, + payable_account="_Test Payroll Payable - _TC", + currency=frappe.db.get_value("Company", "_Test Company", "default_currency"), + department="cc - _TC", + company="_Test Company", + payment_account="Cash - _TC", + cost_center="Main - _TC", + ) + je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry") + je_entries = frappe.db.sql( + """ + select account, cost_center, debit, credit + from `tabJournal Entry Account` + where parent=%s + order by account, cost_center + """, + je, + ) + expected_je = ( + ("_Test Payroll Payable - _TC", "Main - _TC", 0.0, 155600.0), + ("Salary - _TC", "_Test Cost Center - _TC", 124800.0, 0.0), + ("Salary - _TC", "_Test Cost Center 2 - _TC", 31200.0, 0.0), + ("Salary Deductions - _TC", "_Test Cost Center - _TC", 0.0, 320.0), + ("Salary Deductions - _TC", "_Test Cost Center 2 - _TC", 0.0, 80.0), + ) - self.assertEqual(je_entries, expected_je) + self.assertEqual(je_entries, expected_je) def test_get_end_date(self): self.assertEqual(get_end_date("2017-01-01", "monthly"), {"end_date": "2017-01-31"}) @@ -268,31 +214,22 @@ class TestPayrollEntry(unittest.TestCase): self.assertEqual(get_end_date("2017-02-15", "daily"), {"end_date": "2017-02-15"}) def test_loan(self): - branch = "Test Employee Branch" - applicant = make_employee("test_employee@loan.com", company="_Test Company") company = "_Test Company" - holiday_list = make_holiday("test holiday for loan") - - company_doc = frappe.get_doc("Company", company) - if not company_doc.default_payroll_payable_account: - company_doc.default_payroll_payable_account = frappe.db.get_value( - "Account", {"company": company, "root_type": "Liability", "account_type": ""}, "name" - ) - company_doc.save() + branch = "Test Employee Branch" if not frappe.db.exists("Branch", branch): frappe.get_doc({"doctype": "Branch", "branch": branch}).insert() + holiday_list = make_holiday("test holiday for loan") - employee_doc = frappe.get_doc("Employee", applicant) - employee_doc.branch = branch - employee_doc.holiday_list = holiday_list - employee_doc.save() + applicant = make_employee( + "test_employee@loan.com", company="_Test Company", branch=branch, holiday_list=holiday_list + ) + company_doc = frappe.get_doc("Company", company) - salary_structure = "Test Salary Structure for Loan" make_salary_structure( - salary_structure, + "Test Salary Structure for Loan", "Monthly", - employee=employee_doc.name, + employee=applicant, company="_Test Company", currency=company_doc.default_currency, ) @@ -353,11 +290,110 @@ class TestPayrollEntry(unittest.TestCase): self.assertEqual(row.principal_amount, principal_amount) self.assertEqual(row.total_payment, interest_amount + principal_amount) - if salary_slip.docstatus == 0: - frappe.delete_doc("Salary Slip", name) + def test_salary_slip_operation_queueing(self): + company = "_Test Company" + company_doc = frappe.get_doc("Company", company) + employee = make_employee("test_employee@payroll.com", company=company) + setup_salary_structure(employee, company_doc) + + # enqueue salary slip creation via payroll entry + # Payroll Entry status should change to Queued + dates = get_start_end_dates("Monthly", nowdate()) + payroll_entry = get_payroll_entry( + start_date=dates.start_date, + end_date=dates.end_date, + payable_account=company_doc.default_payroll_payable_account, + currency=company_doc.default_currency, + company=company_doc.name, + cost_center="Main - _TC", + ) + frappe.flags.enqueue_payroll_entry = True + payroll_entry.submit() + payroll_entry.reload() + + self.assertEqual(payroll_entry.status, "Queued") + frappe.flags.enqueue_payroll_entry = False + + def test_salary_slip_operation_failure(self): + company = "_Test Company" + company_doc = frappe.get_doc("Company", company) + employee = make_employee("test_employee@payroll.com", company=company) + + salary_structure = make_salary_structure( + "_Test Salary Structure", + "Monthly", + employee, + company=company, + currency=company_doc.default_currency, + ) + + # reset account in component to test submission failure + component = frappe.get_doc("Salary Component", salary_structure.earnings[0].salary_component) + component.accounts = [] + component.save() + + # salary slip submission via payroll entry + # Payroll Entry status should change to Failed because of the missing account setup + dates = get_start_end_dates("Monthly", nowdate()) + payroll_entry = get_payroll_entry( + start_date=dates.start_date, + end_date=dates.end_date, + payable_account=company_doc.default_payroll_payable_account, + currency=company_doc.default_currency, + company=company_doc.name, + cost_center="Main - _TC", + ) + + # set employee as Inactive to check creation failure + frappe.db.set_value("Employee", employee, "status", "Inactive") + payroll_entry.submit() + payroll_entry.reload() + self.assertEqual(payroll_entry.status, "Failed") + self.assertIsNotNone(payroll_entry.error_message) + + frappe.db.set_value("Employee", employee, "status", "Active") + payroll_entry.submit() + payroll_entry.submit_salary_slips() + + payroll_entry.reload() + self.assertEqual(payroll_entry.status, "Failed") + self.assertIsNotNone(payroll_entry.error_message) + + # set accounts + for data in frappe.get_all("Salary Component", pluck="name"): + set_salary_component_account(data, company_list=[company]) + + # Payroll Entry successful, status should change to Submitted + payroll_entry.submit_salary_slips() + payroll_entry.reload() + + self.assertEqual(payroll_entry.status, "Submitted") + self.assertEqual(payroll_entry.error_message, "") + + def test_payroll_entry_status(self): + company = "_Test Company" + company_doc = frappe.get_doc("Company", company) + employee = make_employee("test_employee@payroll.com", company=company) + + setup_salary_structure(employee, company_doc) + + dates = get_start_end_dates("Monthly", nowdate()) + payroll_entry = get_payroll_entry( + start_date=dates.start_date, + end_date=dates.end_date, + payable_account=company_doc.default_payroll_payable_account, + currency=company_doc.default_currency, + company=company_doc.name, + cost_center="Main - _TC", + ) + payroll_entry.submit() + self.assertEqual(payroll_entry.status, "Submitted") + + payroll_entry.cancel() + self.assertEqual(payroll_entry.status, "Cancelled") -def make_payroll_entry(**args): +def get_payroll_entry(**args): args = frappe._dict(args) payroll_entry = frappe.new_doc("Payroll Entry") @@ -380,8 +416,17 @@ def make_payroll_entry(**args): payroll_entry.payment_account = args.payment_account payroll_entry.fill_employee_details() - payroll_entry.save() - payroll_entry.create_salary_slips() + payroll_entry.insert() + + # Commit so that the first salary slip creation failure does not rollback the Payroll Entry insert. + frappe.db.commit() # nosemgrep + + return payroll_entry + + +def make_payroll_entry(**args): + payroll_entry = get_payroll_entry(**args) + payroll_entry.submit() payroll_entry.submit_salary_slips() if payroll_entry.get_sal_slip_list(ss_status=1): payroll_entry.make_payment_entry() @@ -423,10 +468,17 @@ def make_holiday(holiday_list_name): return holiday_list_name -def get_salary_slip(user, period, salary_structure): - salary_slip = make_employee_salary_slip(user, period, salary_structure) - salary_slip.exchange_rate = 70 - salary_slip.calculate_net_pay() - salary_slip.db_update() +def setup_salary_structure(employee, company_doc, currency=None, salary_structure=None): + for data in frappe.get_all("Salary Component", pluck="name"): + if not frappe.db.get_value( + "Salary Component Account", {"parent": data, "company": company_doc.name}, "name" + ): + set_salary_component_account(data) - return salary_slip + make_salary_structure( + salary_structure or "_Test Salary Structure", + "Monthly", + employee, + company=company_doc.name, + currency=(currency or company_doc.default_currency), + ) diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 60ba2d9a07a..5e3814b73cd 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -1050,10 +1050,10 @@ def make_salary_component(salary_components, test_tax, company_list=None): doc.update(salary_component) doc.insert() - get_salary_component_account(doc, company_list) + set_salary_component_account(doc, company_list) -def get_salary_component_account(sal_comp, company_list=None): +def set_salary_component_account(sal_comp, company_list=None): company = erpnext.get_default_company() if company_list and company not in company_list: diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py index 5c78e8f0375..8cc2ea33142 100644 --- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py +++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py @@ -169,9 +169,6 @@ def make_salary_structure( payroll_period=None, include_flexi_benefits=False, ): - if test_tax: - frappe.db.sql("""delete from `tabSalary Structure` where name=%s""", (salary_structure)) - if frappe.db.exists("Salary Structure", salary_structure): frappe.db.delete("Salary Structure", salary_structure)