From 5b1e1cdfef72d07fd214f3bc88238334327931d9 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Tue, 9 Jun 2026 16:32:36 +0530 Subject: [PATCH 1/9] feat: employer contribution type in salary component refactor(Salary Slip): move component evaluation scope to salary structure assignment --- .../salary_component/salary_component.json | 12 +- .../doctype/salary_slip/salary_slip.py | 200 ++++---------- .../salary_structure/salary_structure.json | 9 +- .../salary_structure/salary_structure.py | 4 +- .../salary_structure_assignment.json | 25 +- .../salary_structure_assignment.py | 245 ++++++++++++++++++ hrms/payroll/utils.py | 100 +++++++ 7 files changed, 434 insertions(+), 161 deletions(-) diff --git a/hrms/payroll/doctype/salary_component/salary_component.json b/hrms/payroll/doctype/salary_component/salary_component.json index 7a8ce311ab..949fc6fb1a 100644 --- a/hrms/payroll/doctype/salary_component/salary_component.json +++ b/hrms/payroll/doctype/salary_component/salary_component.json @@ -69,7 +69,7 @@ "fieldtype": "Select", "in_standard_filter": 1, "label": "Type", - "options": "Earning\nDeduction", + "options": "Earning\nDeduction\nEmployer Contribution", "reqd": 1 }, { @@ -89,6 +89,7 @@ }, { "default": "0", + "depends_on": "eval:doc.type != \"Employer Contribution\"", "fieldname": "do_not_include_in_total", "fieldtype": "Check", "label": "Do Not Include in Total" @@ -118,6 +119,7 @@ }, { "default": "0", + "depends_on": "eval:doc.type != \"Employer Contribution\"", "description": "If enabled, the value specified or calculated in this component will not contribute to the earnings or deductions. However, it's value can be referenced by other components that can be added or deducted. ", "fieldname": "statistical_component", "fieldtype": "Check", @@ -125,6 +127,7 @@ }, { "default": "0", + "depends_on": "eval:doc.type != \"Employer Contribution\"", "fieldname": "is_flexible_benefit", "fieldtype": "Check", "label": "Is Flexible Benefit" @@ -150,7 +153,7 @@ "search_index": 1 }, { - "depends_on": "eval:doc.statistical_component != 1", + "depends_on": "eval:doc.statistical_component != 1 && doc.type != \"Employer Contribution\"", "fieldname": "section_break_5", "fieldtype": "Section Break", "label": "Accounts" @@ -241,7 +244,7 @@ }, { "default": "1", - "depends_on": "eval:!doc.statistical_component", + "depends_on": "eval:!doc.statistical_component && doc.type != \"Employer Contribution\"", "description": "If enabled, the component will not be displayed in the salary slip if the amount is zero", "fieldname": "remove_if_zero_valued", "fieldtype": "Check", @@ -257,6 +260,7 @@ }, { "default": "0", + "depends_on": "eval:doc.type != \"Employer Contribution\"", "description": "If enabled, this component will be included in arrear calculations", "fieldname": "arrear_component", "fieldtype": "Check", @@ -291,7 +295,7 @@ "icon": "fa fa-flag", "index_web_pages_for_search": 1, "links": [], - "modified": "2026-03-26 15:07:25.464240", + "modified": "2026-06-08 17:39:39.847210", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Component", diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index 4a7fd24a46..e4b972284b 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -2,9 +2,6 @@ # License: GNU General Public License v3. See license.txt -import unicodedata -from datetime import date - import frappe from frappe import _, msgprint from frappe.model.document import Document @@ -52,7 +49,12 @@ process_loan_interest_accrual_and_demand, set_loan_repayment, ) -from hrms.payroll.utils import sanitize_expression +from hrms.payroll.utils import ( + COMPONENT_EVAL_GLOBALS, + _safe_eval, + get_component_eval_context, + throw_error_message, +) from hrms.utils.holiday_list import get_holiday_dates_between # cache keys @@ -148,19 +150,7 @@ class SalarySlip(TransactionBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.default_series = f"Sal Slip/{self.employee}/.#####" - self.whitelisted_globals = { - "int": int, - "float": float, - "long": int, - "round": round, - "rounded": rounded, - "date": date, - "getdate": getdate, - "get_first_day": get_first_day, - "get_last_day": get_last_day, - "ceil": ceil, - "floor": floor, - } + self.whitelisted_globals = COMPONENT_EVAL_GLOBALS.copy() def autoname(self): if not self.has_custom_naming_series: @@ -453,12 +443,10 @@ def get_emp_and_working_day_details(self) -> None: struct = self.check_sal_struct() if struct: - self.set_salary_structure_doc() - self.salary_slip_based_on_timesheet = ( - self._salary_structure_doc.salary_slip_based_on_timesheet or 0 - ) + ts_config = self._get_ssa_doc().get_timesheet_config() + self.salary_slip_based_on_timesheet = ts_config.based_on_timesheet self.set_time_sheet() - self.pull_sal_struct() + self.pull_sal_struct(ts_config) process_loan_interest_accrual_and_demand(self) @@ -526,19 +514,17 @@ def check_sal_struct(self): title=_("Salary Structure Missing"), ) - def pull_sal_struct(self): + def pull_sal_struct(self, ts_config=None): from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip if self.salary_slip_based_on_timesheet: - self.salary_structure = self._salary_structure_doc.name - self.hour_rate = self._salary_structure_doc.hour_rate + self.hour_rate = flt(ts_config.hour_rate) self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0 - wages_amount = self.hour_rate * self.total_working_hours + # the hourly-wage earning row (hour_rate * total_working_hours) is built by the + # Salary Structure Assignment in get_evaluated_components - self.add_earning_for_hourly_wages(self, self._salary_structure_doc.salary_component, wages_amount) - - make_salary_slip(self._salary_structure_doc.name, self) + make_salary_slip(self.salary_structure, self) def get_working_days_details(self, lwp=None, for_preview=0, lwp_days_corrected=None): payroll_settings = frappe.get_cached_value( @@ -882,25 +868,6 @@ def calculate_lwp_ppl_and_absent_days_based_on_attendance( return lwp, absent - def add_earning_for_hourly_wages(self, doc, salary_component, amount): - row_exists = False - for row in doc.earnings: - if row.salary_component == salary_component: - row.amount = amount - row_exists = True - break - - if not row_exists: - wages_row = get_salary_component_data(salary_component) - wages_amount = self.hour_rate * self.total_working_hours - - self.update_component_row( - wages_row, - wages_amount, - "earnings", - default_amount=wages_amount, - ) - def set_salary_structure_assignment(self): self._salary_structure_assignment = frappe.db.get_value( "Salary Structure Assignment", @@ -1195,7 +1162,7 @@ def compute_annual_deductions_before_tax_calculation(self): current_period_exempted_amount += d.amount # Future period exempted amount - for deduction in self._salary_structure_doc.get("deductions"): + for deduction in self._evaluated_components["deductions"]: if deduction.exempted_from_income_tax: if deduction.amount_based_on_formula: for sub_period in range(1, ceil(self.remaining_sub_periods)): @@ -1247,8 +1214,8 @@ def calculate_component_amounts(self, component_type): self.accrued_benefits = [] self.benefit_ledger_components = [] - if not getattr(self, "_salary_structure_doc", None): - self.set_salary_structure_doc() + if not getattr(self, "_evaluated_components", None): + self._set_evaluated_components() self.add_structure_components(component_type) self.add_additional_salary_components(component_type) @@ -1257,32 +1224,40 @@ def calculate_component_amounts(self, component_type): else: self.add_tax_components() - def set_salary_structure_doc(self) -> None: - self._salary_structure_doc = frappe.get_cached_doc("Salary Structure", self.salary_structure) - # sanitize condition and formula fields - for table in ("earnings", "deductions"): - for row in self._salary_structure_doc.get(table): - row.condition = sanitize_expression(row.condition) - row.formula = sanitize_expression(row.formula) + def _set_evaluated_components(self) -> None: + """Ask the Salary Structure Assignment to evaluate all component formulas + once and return fully-resolved rows (with default_amount + flags). Shared + across the earnings and deductions passes so cross-component references + (e.g. a deduction referencing an earning abbr) resolve correctly.""" + comps = self._get_ssa_doc().get_evaluated_components(self.total_working_hours) + self._evaluated_components = comps + self._timesheet_component = comps.timesheet_component + + def _get_ssa_doc(self): + if not getattr(self, "_ssa_doc", None): + if not hasattr(self, "_salary_structure_assignment"): + self.set_salary_structure_assignment() + self._ssa_doc = frappe.get_cached_doc( + "Salary Structure Assignment", self._salary_structure_assignment.name + ) + return self._ssa_doc def add_structure_components(self, component_type): self.data, self.default_data = self.get_data_for_eval() - for struct_row in self._salary_structure_doc.get(component_type): + for struct_row in self._evaluated_components[component_type]: self.add_structure_component(struct_row, component_type) def add_structure_component(self, struct_row, component_type): - if ( - self.salary_slip_based_on_timesheet - and struct_row.salary_component == self._salary_structure_doc.salary_component - ): - return - + # struct_row is a resolved row from the Salary Structure Assignment carrying the + # component's formula/condition/flags. The slip evaluates it against its own context: + # - self.data: payment-days prorated values -> the actual `amount` + # - self.default_data: full-cycle values -> the `default_amount` + # (proration cascades through dependent formulas, e.g. SA = BS * 0.5 inherits BS's proration). amount = self.eval_condition_and_formula(struct_row, self.data) if struct_row.statistical_component or struct_row.accrual_component: - # update statitical component amount in reference data based on payment days + # update statistical component amount in reference data based on payment days # since row for statistical component is not added to salary slip - self.default_data[struct_row.abbr] = flt(amount) if struct_row.depends_on_payment_days: amount = ( @@ -1290,7 +1265,7 @@ def add_structure_component(self, struct_row, component_type): if self.total_working_days else 0 ) - self.data[struct_row.abbr] = flt(amount, struct_row.precision("amount")) + self.data[struct_row.abbr] = flt(amount, struct_row.precision) is_accrual_component = ( component_type == "earnings" @@ -1298,7 +1273,6 @@ def add_structure_component(self, struct_row, component_type): and hasattr(self, "benefit_ledger_components") ) if is_accrual_component: - # add accrual component to Accrued Benefits table and track in Employee Benefit Ledger self.append( "accrued_benefits", { @@ -1342,19 +1316,18 @@ def add_structure_component(self, struct_row, component_type): def get_data_for_eval(self): """Returns data for evaluating formula""" - data = frappe._dict() - employee = frappe.get_cached_doc("Employee", self.employee).as_dict() - if not hasattr(self, "_salary_structure_assignment"): self.set_salary_structure_assignment() - data.update(self._salary_structure_assignment) + data = get_component_eval_context( + self.employee, + self._salary_structure_assignment, + self.get_component_abbr_map(), + ) + # overlay salary-slip fields (payment_days, start_date, end_date, etc.) data.update(self.as_dict()) - data.update(employee) - data.update(self.get_component_abbr_map()) - - # shallow copy of data to store default amounts (without payment days) for tax calculation + # shallow copy to store default amounts (without payment-days proration) for tax calculation default_data = data.copy() for key in ("earnings", "deductions"): @@ -1379,9 +1352,8 @@ def eval_condition_and_formula(self, struct_row, data): if condition and not _safe_eval(condition, self.whitelisted_globals, data): return None if struct_row.amount_based_on_formula and formula: - amount = flt( - _safe_eval(formula, self.whitelisted_globals, data), struct_row.precision("amount") - ) + # struct_row is a resolved row (frappe._dict) from the SSA; precision is carried as an int + amount = flt(_safe_eval(formula, self.whitelisted_globals, data), struct_row.precision) if amount: data[struct_row.abbr] = amount @@ -1668,7 +1640,7 @@ def add_additional_salary_components(self, component_type): def add_tax_components(self): # Calculate variable_based_on_taxable_salary after all components updated in salary slip tax_components, self.other_deduction_components = [], [] - for d in self._salary_structure_doc.get("deductions"): + for d in self._evaluated_components["deductions"]: if d.variable_based_on_taxable_salary == 1 and not d.formula and not flt(d.amount): tax_components.append(d.salary_component) else: @@ -2139,7 +2111,7 @@ def get_future_recurring_additional_amount(self, additional_salary, monthly_addi def get_amount_based_on_payment_days(self, row): amount, additional_amount = row.amount, row.additional_amount - timesheet_component = self._salary_structure_doc.salary_component + timesheet_component = getattr(self, "_timesheet_component", None) if not row.additional_salary and not row.default_amount: amount, additional_amount = amount, additional_amount @@ -2625,26 +2597,6 @@ def set_missing_values(time_sheet, target): target.append("timesheets", {"time_sheet": doc.name, "working_hours": doc.total_hours}) -def throw_error_message(row, error, title, description=None): - data = frappe._dict( - { - "doctype": row.parenttype, - "name": row.parent, - "doclink": get_link_to_form(row.parenttype, row.parent), - "row_id": row.idx, - "error": error, - "title": title, - "description": description or "", - } - ) - - message = _( - "Error while evaluating the {doctype} {doclink} at row {row_id}.

Error: {error}

Hint: {description}" - ).format(**data) - - frappe.throw(message, title=title) - - def verify_lwp_days_corrected(employee, start_date, end_date, lwp_days_corrected): # Verify that the provided lwp_days_corrected matches actual payroll corrections. PayrollCorrection = frappe.qb.DocType("Payroll Correction") @@ -2680,50 +2632,6 @@ def on_doctype_update(): frappe.db.add_index("Salary Slip", ["employee", "start_date", "end_date"]) -def _safe_eval(code: str, eval_globals: dict | None = None, eval_locals: dict | None = None): - """Old version of safe_eval from framework. - - Note: current frappe.safe_eval transforms code so if you have nested - iterations with too much depth then it can hit recursion limit of python. - There's no workaround for this and people need large formulas in some - countries so this is alternate implementation for that. - - WARNING: DO NOT use this function anywhere else outside of this file. - """ - code = unicodedata.normalize("NFKC", code) - - _check_attributes(code) - - whitelisted_globals = {"int": int, "float": float, "long": int, "round": round} - if not eval_globals: - eval_globals = {} - - eval_globals["__builtins__"] = {} - eval_globals.update(whitelisted_globals) - return eval(code, eval_globals, eval_locals) # nosemgrep - - -def _check_attributes(code: str) -> None: - import ast - - from frappe.utils.safe_exec import UNSAFE_ATTRIBUTES - - unsafe_attrs = set(UNSAFE_ATTRIBUTES).union(["__"]) - {"format"} - - for attribute in unsafe_attrs: - if attribute in code: - raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{attribute}"') - - BLOCKED_NODES = (ast.NamedExpr,) - - tree = ast.parse(code, mode="eval") - for node in ast.walk(tree): - if isinstance(node, BLOCKED_NODES): - raise SyntaxError(f"Operation not allowed: line {node.lineno} column {node.col_offset}") - if isinstance(node, ast.Attribute) and isinstance(node.attr, str) and node.attr in UNSAFE_ATTRIBUTES: - raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{node.attr}"') - - @frappe.whitelist() def enqueue_email_salary_slips(names: list | str) -> None: """enqueue bulk emailing salary slips""" diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.json b/hrms/payroll/doctype/salary_structure/salary_structure.json index b42449bd73..0eb9f359b9 100644 --- a/hrms/payroll/doctype/salary_structure/salary_structure.json +++ b/hrms/payroll/doctype/salary_structure/salary_structure.json @@ -27,6 +27,7 @@ "column_break_besp", "earnings", "deductions", + "employer_contributions", "employee_benefits", "conditions_and_formula_variable_and_example", "net_pay_detail", @@ -162,6 +163,12 @@ "oldfieldtype": "Table", "options": "Salary Detail" }, + { + "fieldname": "employer_contributions", + "fieldtype": "Table", + "label": "Employer Contributions", + "options": "Salary Detail" + }, { "fieldname": "net_pay_detail", "fieldtype": "Section Break", @@ -255,7 +262,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2025-09-15 15:56:52.814944", + "modified": "2026-06-08 17:39:39.847210", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure", diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.py b/hrms/payroll/doctype/salary_structure/salary_structure.py index d0e1749b79..a6d52d721f 100644 --- a/hrms/payroll/doctype/salary_structure/salary_structure.py +++ b/hrms/payroll/doctype/salary_structure/salary_structure.py @@ -169,7 +169,7 @@ def validate_timesheet_component(self): break def sanitize_condition_and_formula_fields(self): - for table in ("earnings", "deductions"): + for table in ("earnings", "deductions", "employer_contributions"): for row in self.get(table): row.condition = row.condition.strip() if row.condition else "" row.formula = row.formula.strip() if row.formula else "" @@ -178,7 +178,7 @@ def sanitize_condition_and_formula_fields(self): def reset_condition_and_formula_fields(self): # set old values (allowing multiline strings for better readability in the doctype form) - for table in ("earnings", "deductions"): + for table in ("earnings", "deductions", "employer_contributions"): for row in self.get(table): row.condition = row._condition row.formula = row._formula diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json index 767c24ec75..d5da6e5b8d 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json @@ -22,9 +22,10 @@ "currency", "section_break_7", "base", - "ctc", + "annual_gross_earning", "column_break_9", "variable", + "ctc", "amended_from", "column_break_kjvm", "leave_encashment_amount_per_day", @@ -117,6 +118,20 @@ "label": "Base", "options": "currency" }, + { + "fieldname": "annual_gross_earning", + "fieldtype": "Currency", + "label": "Annual Gross Earning", + "options": "currency", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "ctc", + "fieldtype": "Currency", + "label": "Total Cost To Company (CTC)", + "read_only": 1 + }, { "fieldname": "column_break_9", "fieldtype": "Column Break" @@ -243,17 +258,11 @@ "fieldtype": "Currency", "label": "Leave Encashment Amount Per Day", "options": "currency" - }, - { - "allow_on_submit": 1, - "fieldname": "ctc", - "fieldtype": "Currency", - "label": "Total Cost To Company (CTC)" } ], "is_submittable": 1, "links": [], - "modified": "2026-06-05 13:20:40.794089", + "modified": "2026-06-08 17:40:49.118187", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure Assignment", diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 7f6ee935db..24e4f9bd23 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -9,6 +9,39 @@ from hrms.payroll.doctype.payroll_period.payroll_period import get_payroll_period from hrms.payroll.doctype.salary_structure.salary_structure import validate_max_benefit_for_flexible_benefit +from hrms.payroll.utils import ( + COMPONENT_EVAL_GLOBALS, + _safe_eval, + get_component_eval_context, + sanitize_expression, + throw_error_message, +) + +# Fields copied from the salary structure component row onto each resolved row +# handed to the salary slip. The slip reads these to build/identify slip rows. +RESOLVED_ROW_FLAGS = ( + "salary_component", + "abbr", + "amount_based_on_formula", + "statistical_component", + "accrual_component", + "depends_on_payment_days", + "do_not_include_in_total", + "do_not_include_in_accounts", + "is_tax_applicable", + "is_flexible_benefit", + "variable_based_on_taxable_salary", + "exempted_from_income_tax", + "deduct_full_tax_on_selected_payroll_date", +) + +PERIODS_PER_YEAR = { + "Monthly": 12, + "Fortnightly": 26, + "Bimonthly": 24, + "Weekly": 52, + "Daily": 365, +} class DuplicateAssignment(frappe.ValidationError): @@ -28,6 +61,7 @@ class SalaryStructureAssignment(Document): from hrms.payroll.doctype.employee_cost_center.employee_cost_center import EmployeeCostCenter amended_from: DF.Link | None + annual_gross_earning: DF.Currency base: DF.Currency company: DF.Link ctc: DF.Currency @@ -62,6 +96,7 @@ def validate(self): self.validate_cost_centers() self.warn_about_missing_opening_entries() + self.calculate_ctc_and_gross() def on_update_after_submit(self): self.validate_cost_centers() @@ -201,6 +236,216 @@ def warn_about_missing_opening_entries(self): title=_("Missing Opening Entries"), ) + def get_evaluated_components(self, total_working_hours: float = 0) -> frappe._dict: + """Evaluate all salary structure components for this assignment and return + fully-resolved rows the salary slip can consume directly. + + Earnings, deductions and employer contributions are evaluated in one + shared pass (so a deduction formula can reference an earning abbr), each + row carrying its full-cycle ``default_amount`` plus the flags the slip + needs. For timesheet-based structures the hourly-wage earning is set to + ``hour_rate * total_working_hours``. The slip applies payment-days + proration and tax computation on top; it does not re-evaluate formulas. + """ + data, rows_by_type = self._evaluate_all_components() + ts_config = self.get_timesheet_config() + + if ts_config.based_on_timesheet and ts_config.timesheet_component: + self._apply_timesheet_wage(rows_by_type["earnings"], ts_config, flt(total_working_hours)) + + return frappe._dict( + earnings=rows_by_type["earnings"], + deductions=rows_by_type["deductions"], + employer_contributions=rows_by_type["employer_contributions"], + data=data, + based_on_timesheet=ts_config.based_on_timesheet, + hour_rate=ts_config.hour_rate, + timesheet_component=ts_config.timesheet_component, + ) + + def get_timesheet_config(self) -> frappe._dict: + """Lightweight read of the linked structure's timesheet settings, needed + by the slip early (before component evaluation runs).""" + ss = ( + frappe.get_cached_value( + "Salary Structure", + self.salary_structure, + ["salary_slip_based_on_timesheet", "hour_rate", "salary_component"], + as_dict=True, + ) + or frappe._dict() + ) + return frappe._dict( + based_on_timesheet=cint(ss.salary_slip_based_on_timesheet), + hour_rate=flt(ss.hour_rate), + timesheet_component=ss.salary_component, + ) + + def calculate_ctc_and_gross(self) -> None: + if not self.base or not self.salary_structure: + return + + salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure) + periods = PERIODS_PER_YEAR.get(salary_structure.payroll_frequency, 12) + + _data, rows_by_type = self._evaluate_all_components() + + # Statistical components are notional (referenced by formulas only); they + # must not contribute to gross or CTC. + monthly_gross = sum( + flt(r.default_amount) for r in rows_by_type["earnings"] if not r.statistical_component + ) + monthly_employer = sum( + flt(r.default_amount) + for r in rows_by_type["employer_contributions"] + if not r.statistical_component + ) + + self.annual_gross_earning = flt(monthly_gross * periods, self.precision("annual_gross_earning")) + self.ctc = flt((monthly_gross + monthly_employer) * periods, self.precision("ctc")) + + def _evaluate_all_components(self) -> tuple[frappe._dict, dict]: + """Single shared-context pass over earnings -> deductions -> + employer_contributions. Returns the final context and resolved rows by + type. Does not mutate the cached salary structure doc.""" + salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure) + data = self._get_component_eval_context() + + rows_by_type = {} + rows_by_type["earnings"] = self._evaluate_component_table( + salary_structure.get("earnings") or [], data + ) + + # Expose full-cycle gross_pay so deduction / employer-contribution formulas can + # reference it (e.g. PF, ESI), mirroring how the salary slip sets gross_pay after + # earnings and before deductions. + data["gross_pay"] = sum( + flt(r.default_amount) + for r in rows_by_type["earnings"] + if not r.statistical_component and not r.do_not_include_in_total + ) + + rows_by_type["deductions"] = self._evaluate_component_table( + salary_structure.get("deductions") or [], data + ) + rows_by_type["employer_contributions"] = self._evaluate_component_table( + salary_structure.get("employer_contributions") or [], data + ) + return data, rows_by_type + + def _get_component_eval_context(self) -> frappe._dict: + abbr_map = {abbr: 0 for abbr in frappe.get_all("Salary Component", pluck="salary_component_abbr")} + return get_component_eval_context(self.employee, self.as_dict(), abbr_map) + + def _evaluate_component_table(self, rows, data: frappe._dict) -> list: + """Evaluate one component table against the shared ``data`` (mutating it + with each component's full-cycle amount). Returns fresh ``frappe._dict`` + rows (cache-safe copies). Raises a clear error on a bad formula/condition. + Rows whose condition is falsey are skipped (not added to the slip).""" + resolved = [] + for struct_row in rows: + condition = sanitize_expression(struct_row.condition) + formula = sanitize_expression(struct_row.formula) + amount = flt(struct_row.amount) + + try: + if condition and not _safe_eval(condition, COMPONENT_EVAL_GLOBALS.copy(), data): + continue + if struct_row.amount_based_on_formula and formula: + default_amount = flt( + _safe_eval(formula, COMPONENT_EVAL_GLOBALS.copy(), data), + struct_row.precision("amount"), + ) + else: + default_amount = amount + except NameError as ne: + throw_error_message( + struct_row, + ne, + title=_("Name error"), + description=_("This error can be due to missing or deleted field."), + ) + except SyntaxError as se: + throw_error_message( + struct_row, + se, + title=_("Syntax error"), + description=_("This error can be due to invalid syntax."), + ) + except Exception as exc: + throw_error_message( + struct_row, + exc, + title=_("Error in formula or condition"), + description=_("This error can be due to invalid formula or condition."), + ) + raise + + data[struct_row.abbr] = default_amount + + resolved_row = frappe._dict( + default_amount=default_amount, + amount=amount, + condition=condition, + formula=formula, + precision=struct_row.precision("amount"), + ) + for field in RESOLVED_ROW_FLAGS: + resolved_row[field] = struct_row.get(field) + resolved.append(resolved_row) + + return resolved + + def _apply_timesheet_wage( + self, earnings: list, ts_config: frappe._dict, total_working_hours: float + ) -> None: + """Add the timesheet wage earning (hour_rate * total_working_hours) as the + first earning. Any copy of the wage component declared in the structure + earnings is dropped first, mirroring legacy behaviour where the hourly-wage + row was added before the structure components.""" + wages_amount = flt(ts_config.hour_rate) * flt(total_working_hours) + earnings[:] = [r for r in earnings if r.salary_component != ts_config.timesheet_component] + earnings.insert(0, self._build_wage_row(ts_config.timesheet_component, wages_amount)) + + def _build_wage_row(self, component: str, amount: float) -> frappe._dict: + """Build a resolved earning row for a timesheet wage component that is not + declared in the structure earnings, from the Salary Component master.""" + comp = ( + frappe.db.get_value( + "Salary Component", + component, + ( + "name as salary_component", + "salary_component_abbr as abbr", + "depends_on_payment_days", + "do_not_include_in_total", + "do_not_include_in_accounts", + "is_tax_applicable", + "is_flexible_benefit", + "variable_based_on_taxable_salary", + "accrual_component", + "exempted_from_income_tax", + "statistical_component", + "deduct_full_tax_on_selected_payroll_date", + ), + as_dict=True, + cache=True, + ) + or frappe._dict() + ) + row = frappe._dict( + default_amount=amount, + amount=amount, + condition=None, + formula=None, + precision=2, + ) + for field in RESOLVED_ROW_FLAGS: + row[field] = comp.get(field) + row.amount_based_on_formula = 0 + row.salary_component = comp.get("salary_component") or component + return row + @frappe.whitelist() def are_opening_entries_required(self) -> bool: if not get_tax_component(self.salary_structure): diff --git a/hrms/payroll/utils.py b/hrms/payroll/utils.py index 61187336c2..b615801ed8 100644 --- a/hrms/payroll/utils.py +++ b/hrms/payroll/utils.py @@ -1,4 +1,9 @@ +import unicodedata +from datetime import date + import frappe +from frappe import _ +from frappe.utils import ceil, floor, flt, get_first_day, get_last_day, get_link_to_form, getdate, rounded def sanitize_expression(string: str | None = None) -> str | None: @@ -26,6 +31,101 @@ def sanitize_expression(string: str | None = None) -> str | None: return string +COMPONENT_EVAL_GLOBALS = { + "int": int, + "float": float, + "long": int, + "round": round, + "rounded": rounded, + "date": date, + "getdate": getdate, + "get_first_day": get_first_day, + "get_last_day": get_last_day, + "ceil": ceil, + "floor": floor, + "min": min, + "max": max, +} + + +def get_component_eval_context(employee: str, ssa_as_dict: dict, abbr_map: dict) -> frappe._dict: + """Build the base evaluation context for salary component formulas. + + Merges component abbreviation defaults, SSA fields (base, variable) and + employee fields so that formulas can reference any of them by name. + SSA fields are applied before employee fields so that employee attributes + (employment_type, date_of_joining, …) take precedence over any same-named + SSA fields. base and variable are pinned last to guarantee correct values. + """ + data = frappe._dict() + data.update(abbr_map) + data.update(ssa_as_dict) + data.update(frappe.get_cached_doc("Employee", employee).as_dict()) + data["base"] = flt(ssa_as_dict.get("base") or 0) + data["variable"] = flt(ssa_as_dict.get("variable") or 0) + return data + + +def _check_attributes(code: str) -> None: + import ast + + from frappe.utils.safe_exec import UNSAFE_ATTRIBUTES + + unsafe_attrs = set(UNSAFE_ATTRIBUTES).union(["__"]) - {"format"} + + for attribute in unsafe_attrs: + if attribute in code: + raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{attribute}"') + + BLOCKED_NODES = (ast.NamedExpr,) + + tree = ast.parse(code, mode="eval") + for node in ast.walk(tree): + if isinstance(node, BLOCKED_NODES): + raise SyntaxError(f"Operation not allowed: line {node.lineno} column {node.col_offset}") + if isinstance(node, ast.Attribute) and isinstance(node.attr, str) and node.attr in UNSAFE_ATTRIBUTES: + raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{node.attr}"') + + +def _safe_eval(code: str, eval_globals: dict | None = None, eval_locals: dict | None = None): + """Safe eval for salary component conditions and formulas. + + Uses AST-based attribute checking instead of frappe.safe_eval to avoid + recursion limit issues with deeply nested formulas. + """ + code = unicodedata.normalize("NFKC", code) + + _check_attributes(code) + + whitelisted_globals = {"int": int, "float": float, "long": int, "round": round} + if not eval_globals: + eval_globals = {} + + eval_globals["__builtins__"] = {} + eval_globals.update(whitelisted_globals) + return eval(code, eval_globals, eval_locals) # nosemgrep + + +def throw_error_message(row, error, title, description=None): + data = frappe._dict( + { + "doctype": row.parenttype, + "name": row.parent, + "doclink": get_link_to_form(row.parenttype, row.parent), + "row_id": row.idx, + "error": error, + "title": title, + "description": description or "", + } + ) + + message = _( + "Error while evaluating the {doctype} {doclink} at row {row_id}.

Error: {error}

Hint: {description}" + ).format(**data) + + frappe.throw(message, title=title) + + @frappe.whitelist() def get_payroll_settings_for_payment_days() -> dict: return frappe.get_cached_value( From f884b21c6d4846727cd70f85913116b98b674db6 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Tue, 9 Jun 2026 16:55:58 +0530 Subject: [PATCH 2/9] test: modify test_ctc_validation because ctc not auto calculates --- .../report/employee_ctc_break_up/test_employee_ctc_breakup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py b/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py index 6d27b982b1..f48bafa09b 100644 --- a/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py +++ b/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py @@ -249,6 +249,8 @@ def test_ctc_validation(self): salary_structure_assignment = create_salary_structure_assignment( employee, salary_structure.name, base=60000, currency="INR", from_date=get_year_start(getdate()) ) + # salary structure assignment auto calculates ctc on save, removing it manually for the test + salary_structure_assignment.db_set("ctc", 0) self.assertRaises( frappe.ValidationError, SalaryBreakupReport, employee, salary_structure_assignment.name ) From 24d8e049487a7031949a29101721c6396d39e2c1 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Wed, 10 Jun 2026 15:06:38 +0530 Subject: [PATCH 3/9] refactor: use salary structure assignment's calculations for preview of salary slip test: employer's contribution components --- .../doctype/salary_slip/salary_slip.py | 22 ++-- .../salary_structure/salary_structure.py | 17 +++ .../salary_structure_assignment.py | 28 +++-- .../test_salary_structure_assignment.py | 109 +++++++++++++++++- hrms/payroll/utils.py | 20 +++- 5 files changed, 173 insertions(+), 23 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index e4b972284b..e521a8a3fa 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -52,6 +52,7 @@ from hrms.payroll.utils import ( COMPONENT_EVAL_GLOBALS, _safe_eval, + get_component_abbr_map, get_component_eval_context, throw_error_message, ) @@ -60,6 +61,7 @@ # cache keys HOLIDAYS_BETWEEN_DATES = "holidays_between_dates" LEAVE_TYPE_MAP = "leave_type_map" +# NOTE: must match the key used in hrms.payroll.utils.get_component_abbr_map SALARY_COMPONENT_VALUES = "salary_component_values" TAX_COMPONENTS_BY_COMPANY = "tax_components_by_company" @@ -1304,7 +1306,9 @@ def add_structure_component(self, struct_row, component_type): or (struct_row.amount_based_on_formula and amount is not None) or (not remove_if_zero_valued and amount is not None and not self.data[struct_row.abbr]) ): - default_amount = self.eval_condition_and_formula(struct_row, self.default_data) + # full-cycle default comes from SSA (period-independent); the slip only + # computes the prorated `amount` above (proration is a period concern) + default_amount = flt(struct_row.default_amount) self.update_component_row( struct_row, amount, @@ -1322,9 +1326,12 @@ def get_data_for_eval(self): data = get_component_eval_context( self.employee, self._salary_structure_assignment, - self.get_component_abbr_map(), + get_component_abbr_map(), ) - # overlay salary-slip fields (payment_days, start_date, end_date, etc.) + # Overlay salary-slip fields (payment_days, gross_pay, start_date, …) last, so the + # actual period context wins. Note: this means on a name collision a Salary Slip + # field takes precedence over an Employee field (employee is layered earlier in + # get_component_eval_context); no current formula relies on the reverse. data.update(self.as_dict()) # shallow copy to store default amounts (without payment-days proration) for tax calculation @@ -1337,15 +1344,6 @@ def get_data_for_eval(self): return data, default_data - def get_component_abbr_map(self): - def _fetch_component_values(): - return { - component_abbr: 0 - for component_abbr in frappe.get_all("Salary Component", pluck="salary_component_abbr") - } - - return frappe.cache().get_value(SALARY_COMPONENT_VALUES, generator=_fetch_component_values) - def eval_condition_and_formula(self, struct_row, data): try: condition, formula, amount = struct_row.condition, struct_row.formula, struct_row.amount diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.py b/hrms/payroll/doctype/salary_structure/salary_structure.py index a6d52d721f..4a4a8b5722 100644 --- a/hrms/payroll/doctype/salary_structure/salary_structure.py +++ b/hrms/payroll/doctype/salary_structure/salary_structure.py @@ -62,8 +62,25 @@ def validate(self): self.validate_payment_days_based_dependent_component() self.validate_timesheet_component() self.validate_formula_setup() + self.validate_employer_contributions() validate_max_benefit_for_flexible_benefit(self.employee_benefits, self.max_benefits) + def validate_employer_contributions(self): + for row in self.get("employer_contributions") or []: + component_type = frappe.get_cached_value("Salary Component", row.salary_component, "type") + if component_type != "Employer Contribution": + frappe.throw( + _( + "Row #{0}: Salary Component {1} in Employer Contributions must be of type {2}, not {3}." + ).format( + row.idx, + frappe.bold(row.salary_component), + frappe.bold(_("Employer Contribution")), + frappe.bold(_(component_type)), + ), + title=_("Invalid Employer Contribution Component"), + ) + def on_update(self): self.reset_condition_and_formula_fields() diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 24e4f9bd23..bb27b3b344 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -12,6 +12,7 @@ from hrms.payroll.utils import ( COMPONENT_EVAL_GLOBALS, _safe_eval, + get_component_abbr_map, get_component_eval_context, sanitize_expression, throw_error_message, @@ -244,10 +245,11 @@ def get_evaluated_components(self, total_working_hours: float = 0) -> frappe._di shared pass (so a deduction formula can reference an earning abbr), each row carrying its full-cycle ``default_amount`` plus the flags the slip needs. For timesheet-based structures the hourly-wage earning is set to - ``hour_rate * total_working_hours``. The slip applies payment-days - proration and tax computation on top; it does not re-evaluate formulas. + ``hour_rate * total_working_hours``. The slip consumes ``default_amount`` + directly and applies payment-days proration / tax on top (it re-evaluates + each formula once against its prorated context for the actual ``amount``). """ - data, rows_by_type = self._evaluate_all_components() + _data, rows_by_type = self._evaluate_all_components() ts_config = self.get_timesheet_config() if ts_config.based_on_timesheet and ts_config.timesheet_component: @@ -257,9 +259,6 @@ def get_evaluated_components(self, total_working_hours: float = 0) -> frappe._di earnings=rows_by_type["earnings"], deductions=rows_by_type["deductions"], employer_contributions=rows_by_type["employer_contributions"], - data=data, - based_on_timesheet=ts_config.based_on_timesheet, - hour_rate=ts_config.hour_rate, timesheet_component=ts_config.timesheet_component, ) @@ -283,6 +282,8 @@ def get_timesheet_config(self) -> frappe._dict: def calculate_ctc_and_gross(self) -> None: if not self.base or not self.salary_structure: + self.annual_gross_earning = 0 + self.ctc = 0 return salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure) @@ -334,8 +335,19 @@ def _evaluate_all_components(self) -> tuple[frappe._dict, dict]: return data, rows_by_type def _get_component_eval_context(self) -> frappe._dict: - abbr_map = {abbr: 0 for abbr in frappe.get_all("Salary Component", pluck="salary_component_abbr")} - return get_component_eval_context(self.employee, self.as_dict(), abbr_map) + data = get_component_eval_context(self.employee, self.as_dict(), get_component_abbr_map()) + + # Full-cycle / preview seeding: SSA has no attendance, so it evaluates as a + # full period -- payment_days == total_working_days (proration ratio 1) and no + # LWP -- so formulas referencing slip-runtime fields resolve and yield + # full-cycle values (definitionally a for_preview slip's value). + frequency = frappe.get_cached_value("Salary Structure", self.salary_structure, "payroll_frequency") + period_days = round(365 / PERIODS_PER_YEAR.get(frequency, 12)) + data.payment_days = period_days + data.total_working_days = period_days + data.leave_without_pay = 0 + data.absent_days = 0 + return data def _evaluate_component_table(self, rows, data: frappe._dict) -> list: """Evaluate one component table against the shared ``data`` (mutating it diff --git a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py index 9dee000e7f..d9051732a2 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py @@ -1,8 +1,115 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import frappe +from frappe.utils import get_first_day, nowdate + +from erpnext.setup.doctype.employee.test_employee import make_employee + +from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure from hrms.tests.utils import HRMSTestSuite +def _make_component(name, abbr, comp_type="Earning", **flags): + if frappe.db.exists("Salary Component", name): + frappe.delete_doc("Salary Component", name, force=True) + doc = frappe.new_doc("Salary Component") + doc.update({"salary_component": name, "salary_component_abbr": abbr, "type": comp_type}) + doc.update(flags) + return doc.insert() + + class TestSalaryStructureAssignment(HRMSTestSuite): - pass + def test_ctc_and_annual_gross_exclude_statistical_and_include_employer(self): + """base=50000; gross = Basic (50000) only — statistical earning (1000) is + excluded; CTC = gross + employer contribution (6000).""" + emp = make_employee("ssa_ctc_calc@test.com", company="_Test Company") + + _make_component("SSA Test Basic", "SSATB", "Earning", amount_based_on_formula=1, formula="base") + _make_component("SSA Test Statistical", "SSATS", "Earning", statistical_component=1) + _make_component("SSA Test Employer PF", "SSATEPF", "Employer Contribution") + + earnings = [ + { + "salary_component": "SSA Test Basic", + "abbr": "SSATB", + "amount_based_on_formula": 1, + "formula": "base", + }, + { + "salary_component": "SSA Test Statistical", + "abbr": "SSATS", + "statistical_component": 1, + "amount": 1000, + }, + ] + employer_contributions = [ + {"salary_component": "SSA Test Employer PF", "abbr": "SSATEPF", "amount": 6000}, + ] + + make_salary_structure( + "SSA Test CTC Structure", + "Monthly", + employee=emp, + company="_Test Company", + base=50000, + earnings=earnings, + deductions=[], + other_details={"employer_contributions": employer_contributions}, + ) + ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp}) + + self.assertEqual(ssa.annual_gross_earning, 50000 * 12) + self.assertEqual(ssa.ctc, (50000 + 6000) * 12) + + def test_ctc_reset_when_base_missing(self): + emp = make_employee("ssa_ctc_nobase@test.com", company="_Test Company") + make_salary_structure("SSA Test No Base Structure", "Monthly", company="_Test Company") + ssa = frappe.new_doc("Salary Structure Assignment") + ssa.employee = emp + ssa.salary_structure = "SSA Test No Base Structure" + ssa.company = "_Test Company" + ssa.from_date = get_first_day(nowdate()) + ssa.base = 0 + ssa.calculate_ctc_and_gross() + self.assertEqual(ssa.ctc, 0) + self.assertEqual(ssa.annual_gross_earning, 0) + + def test_get_evaluated_components_does_not_mutate_cached_structure(self): + emp = make_employee("ssa_cache@test.com", company="_Test Company") + make_salary_structure( + "SSA Test Cache Structure", "Monthly", employee=emp, company="_Test Company", base=50000 + ) + ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp}) + + ssa.get_evaluated_components() + # the cached Salary Structure doc's earning rows must not carry a stamped default_amount + cached = frappe.get_cached_doc("Salary Structure", "SSA Test Cache Structure") + self.assertTrue(all(not r.get("default_amount") for r in cached.earnings)) + + # two calls return independent row lists + first = ssa.get_evaluated_components().earnings + second = ssa.get_evaluated_components().earnings + self.assertIsNot(first, second) + + def test_employer_contribution_must_be_correct_type(self): + emp = make_employee("ssa_empc_type@test.com", company="_Test Company") + _make_component("SSA Test Earning Comp", "SSATEC", "Earning") + earnings = [{"salary_component": "SSA Test Earning Comp", "abbr": "SSATEC", "amount": 50000}] + # an Earning component placed in employer_contributions must be rejected + self.assertRaises( + frappe.ValidationError, + make_salary_structure, + "SSA Test Bad Employer Structure", + "Monthly", + employee=emp, + company="_Test Company", + base=50000, + earnings=earnings, + deductions=[], + other_details={ + "employer_contributions": [ + {"salary_component": "SSA Test Earning Comp", "abbr": "SSATEC", "amount": 6000} + ] + }, + ) diff --git a/hrms/payroll/utils.py b/hrms/payroll/utils.py index b615801ed8..0b3eacd482 100644 --- a/hrms/payroll/utils.py +++ b/hrms/payroll/utils.py @@ -48,6 +48,19 @@ def sanitize_expression(string: str | None = None) -> str | None: } +def get_component_abbr_map() -> dict: + """Cached {salary_component_abbr: 0} map, seeded into the formula eval context + so any component abbreviation referenced in a formula resolves (default 0). + + Cache key matches salary_slip.SALARY_COMPONENT_VALUES (shared entry, invalidated + on Salary Component save).""" + + def _fetch_component_values(): + return {abbr: 0 for abbr in frappe.get_all("Salary Component", pluck="salary_component_abbr")} + + return frappe.cache().get_value("salary_component_values", generator=_fetch_component_values) + + def get_component_eval_context(employee: str, ssa_as_dict: dict, abbr_map: dict) -> frappe._dict: """Build the base evaluation context for salary component formulas. @@ -88,10 +101,13 @@ def _check_attributes(code: str) -> None: def _safe_eval(code: str, eval_globals: dict | None = None, eval_locals: dict | None = None): - """Safe eval for salary component conditions and formulas. + """Safe eval for **trusted** salary component conditions and formulas only. Uses AST-based attribute checking instead of frappe.safe_eval to avoid - recursion limit issues with deeply nested formulas. + recursion limit issues with the large/deeply-nested formulas some countries' + payroll needs. It is a lighter (denylist-based) sandbox than frappe.safe_eval, + so it is safe only for admin-authored salary-structure formulas, not arbitrary + or end-user input. For anything else, use frappe.safe_eval. """ code = unicodedata.normalize("NFKC", code) From adfc736bbb74a12a44fba4fdbecb8168f3b8799f Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Wed, 10 Jun 2026 15:15:36 +0530 Subject: [PATCH 4/9] fix: filter employers contribution components in the child table --- .../salary_structure/salary_structure.js | 6 +++++ .../salary_structure/salary_structure.py | 17 -------------- .../test_salary_structure_assignment.py | 22 ------------------- 3 files changed, 6 insertions(+), 39 deletions(-) diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.js b/hrms/payroll/doctype/salary_structure/salary_structure.js index aab1263553..c75c9c5045 100755 --- a/hrms/payroll/doctype/salary_structure/salary_structure.js +++ b/hrms/payroll/doctype/salary_structure/salary_structure.js @@ -69,6 +69,12 @@ frappe.ui.form.on("Salary Structure", { query: "hrms.payroll.doctype.salary_structure.salary_structure.get_salary_component", }; }); + frm.set_query("salary_component", "employer_contributions", function () { + return { + filters: { component_type: "employer contribution", company: frm.doc.company }, + query: "hrms.payroll.doctype.salary_structure.salary_structure.get_salary_component", + }; + }); }, company: function (frm) { diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.py b/hrms/payroll/doctype/salary_structure/salary_structure.py index 4a4a8b5722..a6d52d721f 100644 --- a/hrms/payroll/doctype/salary_structure/salary_structure.py +++ b/hrms/payroll/doctype/salary_structure/salary_structure.py @@ -62,25 +62,8 @@ def validate(self): self.validate_payment_days_based_dependent_component() self.validate_timesheet_component() self.validate_formula_setup() - self.validate_employer_contributions() validate_max_benefit_for_flexible_benefit(self.employee_benefits, self.max_benefits) - def validate_employer_contributions(self): - for row in self.get("employer_contributions") or []: - component_type = frappe.get_cached_value("Salary Component", row.salary_component, "type") - if component_type != "Employer Contribution": - frappe.throw( - _( - "Row #{0}: Salary Component {1} in Employer Contributions must be of type {2}, not {3}." - ).format( - row.idx, - frappe.bold(row.salary_component), - frappe.bold(_("Employer Contribution")), - frappe.bold(_(component_type)), - ), - title=_("Invalid Employer Contribution Component"), - ) - def on_update(self): self.reset_condition_and_formula_fields() diff --git a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py index d9051732a2..4be68754f6 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py @@ -91,25 +91,3 @@ def test_get_evaluated_components_does_not_mutate_cached_structure(self): first = ssa.get_evaluated_components().earnings second = ssa.get_evaluated_components().earnings self.assertIsNot(first, second) - - def test_employer_contribution_must_be_correct_type(self): - emp = make_employee("ssa_empc_type@test.com", company="_Test Company") - _make_component("SSA Test Earning Comp", "SSATEC", "Earning") - earnings = [{"salary_component": "SSA Test Earning Comp", "abbr": "SSATEC", "amount": 50000}] - # an Earning component placed in employer_contributions must be rejected - self.assertRaises( - frappe.ValidationError, - make_salary_structure, - "SSA Test Bad Employer Structure", - "Monthly", - employee=emp, - company="_Test Company", - base=50000, - earnings=earnings, - deductions=[], - other_details={ - "employer_contributions": [ - {"salary_component": "SSA Test Earning Comp", "abbr": "SSATEC", "amount": 6000} - ] - }, - ) From d2cd378896c63df810e5cc9d4575804e92c26238 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Wed, 10 Jun 2026 16:04:34 +0530 Subject: [PATCH 5/9] fix: exclude do not include in total from annual_gross_earning and include it in ctc --- .../salary_structure_assignment.py | 26 ++++++++---- .../test_salary_structure_assignment.py | 40 +++++++++++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index bb27b3b344..4e62cbcfbb 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -289,21 +289,31 @@ def calculate_ctc_and_gross(self) -> None: salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure) periods = PERIODS_PER_YEAR.get(salary_structure.payroll_frequency, 12) - _data, rows_by_type = self._evaluate_all_components() + data, rows_by_type = self._evaluate_all_components() + + # Reuse the per-period gross already computed in the eval context: payable + # earnings only (excludes statistical and do_not_include_in_total), matching + # the salary slip's gross_pay. + gross_per_period = flt(data.get("gross_pay")) - # Statistical components are notional (referenced by formulas only); they - # must not contribute to gross or CTC. - monthly_gross = sum( - flt(r.default_amount) for r in rows_by_type["earnings"] if not r.statistical_component + # CTC also includes costs that are part of CTC but not payable: do_not_include_in_total + # earnings (shown on the slip, excluded from gross) and employer contributions (off-slip). + non_payable_earnings_per_period = sum( + flt(r.default_amount) + for r in rows_by_type["earnings"] + if not r.statistical_component and r.do_not_include_in_total ) - monthly_employer = sum( + employer_per_period = sum( flt(r.default_amount) for r in rows_by_type["employer_contributions"] if not r.statistical_component ) - self.annual_gross_earning = flt(monthly_gross * periods, self.precision("annual_gross_earning")) - self.ctc = flt((monthly_gross + monthly_employer) * periods, self.precision("ctc")) + self.annual_gross_earning = flt(gross_per_period * periods, self.precision("annual_gross_earning")) + self.ctc = flt( + (gross_per_period + non_payable_earnings_per_period + employer_per_period) * periods, + self.precision("ctc"), + ) def _evaluate_all_components(self) -> tuple[frappe._dict, dict]: """Single shared-context pass over earnings -> deductions -> diff --git a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py index 4be68754f6..56673d5fcd 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py @@ -87,7 +87,39 @@ def test_get_evaluated_components_does_not_mutate_cached_structure(self): cached = frappe.get_cached_doc("Salary Structure", "SSA Test Cache Structure") self.assertTrue(all(not r.get("default_amount") for r in cached.earnings)) - # two calls return independent row lists - first = ssa.get_evaluated_components().earnings - second = ssa.get_evaluated_components().earnings - self.assertIsNot(first, second) + def test_do_not_include_in_total_earning_is_in_ctc_but_not_gross(self): + """A 'Do Not Include in Total' earning is part of CTC but not payable — it + must be excluded from annual_gross_earning yet included in ctc.""" + emp = make_employee("ssa_dniit@test.com", company="_Test Company") + + _make_component("SSA Test Basic Pay", "SSATBP", "Earning", amount_based_on_formula=1, formula="base") + _make_component("SSA Test Company Car", "SSATCAR", "Earning", do_not_include_in_total=1) + + earnings = [ + { + "salary_component": "SSA Test Basic Pay", + "abbr": "SSATBP", + "amount_based_on_formula": 1, + "formula": "base", + }, + { + "salary_component": "SSA Test Company Car", + "abbr": "SSATCAR", + "amount": 2000, + "do_not_include_in_total": 1, + }, + ] + + make_salary_structure( + "SSA Test DNIIT Structure", + "Monthly", + employee=emp, + company="_Test Company", + base=50000, + earnings=earnings, + deductions=[], + ) + ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp}) + + self.assertEqual(ssa.annual_gross_earning, 50000 * 12) + self.assertEqual(ssa.ctc, (50000 + 2000) * 12) From 4f7155ecda7b56419173fd161583c53836289b52 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Wed, 10 Jun 2026 16:49:46 +0530 Subject: [PATCH 6/9] fix: rename ts_config which sounds like a typescript config file to timesheet_config --- .../salary_structure_assignment.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 4e62cbcfbb..df178ce0de 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -250,16 +250,16 @@ def get_evaluated_components(self, total_working_hours: float = 0) -> frappe._di each formula once against its prorated context for the actual ``amount``). """ _data, rows_by_type = self._evaluate_all_components() - ts_config = self.get_timesheet_config() + timesheet_config = self.get_timesheet_config() - if ts_config.based_on_timesheet and ts_config.timesheet_component: - self._apply_timesheet_wage(rows_by_type["earnings"], ts_config, flt(total_working_hours)) + if timesheet_config.based_on_timesheet and timesheet_config.timesheet_component: + self._apply_timesheet_wage(rows_by_type["earnings"], timesheet_config, flt(total_working_hours)) return frappe._dict( earnings=rows_by_type["earnings"], deductions=rows_by_type["deductions"], employer_contributions=rows_by_type["employer_contributions"], - timesheet_component=ts_config.timesheet_component, + timesheet_component=timesheet_config.timesheet_component, ) def get_timesheet_config(self) -> frappe._dict: @@ -419,15 +419,15 @@ def _evaluate_component_table(self, rows, data: frappe._dict) -> list: return resolved def _apply_timesheet_wage( - self, earnings: list, ts_config: frappe._dict, total_working_hours: float + self, earnings: list, timesheet_config: frappe._dict, total_working_hours: float ) -> None: """Add the timesheet wage earning (hour_rate * total_working_hours) as the first earning. Any copy of the wage component declared in the structure earnings is dropped first, mirroring legacy behaviour where the hourly-wage row was added before the structure components.""" - wages_amount = flt(ts_config.hour_rate) * flt(total_working_hours) - earnings[:] = [r for r in earnings if r.salary_component != ts_config.timesheet_component] - earnings.insert(0, self._build_wage_row(ts_config.timesheet_component, wages_amount)) + wages_amount = flt(timesheet_config.hour_rate) * flt(total_working_hours) + earnings[:] = [r for r in earnings if r.salary_component != timesheet_config.timesheet_component] + earnings.insert(0, self._build_wage_row(timesheet_config.timesheet_component, wages_amount)) def _build_wage_row(self, component: str, amount: float) -> frappe._dict: """Build a resolved earning row for a timesheet wage component that is not From cc59e669154916ae0e72800dd1c08bd6fad4c5a8 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Wed, 10 Jun 2026 17:43:48 +0530 Subject: [PATCH 7/9] test: ctc and annual gross earning for salary structure based on timesheet --- .../test_salary_structure_assignment.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py index 56673d5fcd..2392585bb9 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py @@ -123,3 +123,42 @@ def test_do_not_include_in_total_earning_is_in_ctc_but_not_gross(self): self.assertEqual(ssa.annual_gross_earning, 50000 * 12) self.assertEqual(ssa.ctc, (50000 + 2000) * 12) + + def test_ctc_for_timesheet_structure_evaluates_base_driven_components(self): + """For a timesheet-based structure, only the wage component is paid as + hour_rate * hours (variable, excluded from CTC). The rest of the structure + (base-driven components) must still evaluate normally into gross / ctc.""" + emp = make_employee("ssa_ts_ctc@test.com", company="_Test Company") + + # wage component is the structure-level timesheet component, NOT an earning row + _make_component("SSA TS Wage", "SSATSW", "Earning") + _make_component("SSA TS Basic", "SSATSB", "Earning", amount_based_on_formula=1, formula="base") + + earnings = [ + { + "salary_component": "SSA TS Basic", + "abbr": "SSATSB", + "amount_based_on_formula": 1, + "formula": "base", + }, + ] + + make_salary_structure( + "SSA Timesheet Structure", + "Monthly", + employee=emp, + company="_Test Company", + base=50000, + earnings=earnings, + deductions=[], + other_details={ + "salary_slip_based_on_timesheet": 1, + "hour_rate": 50, + "salary_component": "SSA TS Wage", + }, + ) + ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp}) + + # base-driven earning evaluates normally; the variable hourly wage is excluded from CTC + self.assertEqual(ssa.annual_gross_earning, 50000 * 12) + self.assertEqual(ssa.ctc, 50000 * 12) From fe4c73b58555baa1e9ce070b95be33253528500c Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Thu, 11 Jun 2026 02:12:34 +0530 Subject: [PATCH 8/9] fix: move timesheet wage component calculation to salary slip --- .../doctype/salary_slip/salary_slip.py | 46 ++++++++++--- .../salary_structure_assignment.py | 66 ++----------------- .../test_salary_structure_assignment.py | 42 +++++++++++- 3 files changed, 82 insertions(+), 72 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index e521a8a3fa..3cb2c649f5 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -445,10 +445,11 @@ def get_emp_and_working_day_details(self) -> None: struct = self.check_sal_struct() if struct: - ts_config = self._get_ssa_doc().get_timesheet_config() - self.salary_slip_based_on_timesheet = ts_config.based_on_timesheet + timesheet_config = self._get_ssa_doc().get_timesheet_config() + self.salary_slip_based_on_timesheet = timesheet_config.based_on_timesheet + self._timesheet_component = timesheet_config.timesheet_component self.set_time_sheet() - self.pull_sal_struct(ts_config) + self.pull_sal_struct(timesheet_config) process_loan_interest_accrual_and_demand(self) @@ -516,18 +517,38 @@ def check_sal_struct(self): title=_("Salary Structure Missing"), ) - def pull_sal_struct(self, ts_config=None): + def pull_sal_struct(self, timesheet_config=None): from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip if self.salary_slip_based_on_timesheet: - self.hour_rate = flt(ts_config.hour_rate) + self.hour_rate = flt(timesheet_config.hour_rate) self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0 - # the hourly-wage earning row (hour_rate * total_working_hours) is built by the - # Salary Structure Assignment in get_evaluated_components + wages_amount = self.hour_rate * self.total_working_hours + + self.add_earning_for_hourly_wages(self, timesheet_config.timesheet_component, wages_amount) make_salary_slip(self.salary_structure, self) + def add_earning_for_hourly_wages(self, doc, salary_component, amount): + row_exists = False + for row in doc.earnings: + if row.salary_component == salary_component: + row.amount = amount + row_exists = True + break + + if not row_exists: + wages_row = get_salary_component_data(salary_component) + wages_amount = self.hour_rate * self.total_working_hours + + self.update_component_row( + wages_row, + wages_amount, + "earnings", + default_amount=wages_amount, + ) + def get_working_days_details(self, lwp=None, for_preview=0, lwp_days_corrected=None): payroll_settings = frappe.get_cached_value( "Payroll Settings", @@ -1231,9 +1252,7 @@ def _set_evaluated_components(self) -> None: once and return fully-resolved rows (with default_amount + flags). Shared across the earnings and deductions passes so cross-component references (e.g. a deduction referencing an earning abbr) resolve correctly.""" - comps = self._get_ssa_doc().get_evaluated_components(self.total_working_hours) - self._evaluated_components = comps - self._timesheet_component = comps.timesheet_component + self._evaluated_components = self._get_ssa_doc().get_evaluated_components() def _get_ssa_doc(self): if not getattr(self, "_ssa_doc", None): @@ -1251,6 +1270,13 @@ def add_structure_components(self, component_type): self.add_structure_component(struct_row, component_type) def add_structure_component(self, struct_row, component_type): + # the timesheet wage component is added separately (hour_rate * hours) in + # pull_sal_struct, so skip it here to avoid double-adding + if self.salary_slip_based_on_timesheet and struct_row.salary_component == getattr( + self, "_timesheet_component", None + ): + return + # struct_row is a resolved row from the Salary Structure Assignment carrying the # component's formula/condition/flags. The slip evaluates it against its own context: # - self.data: payment-days prorated values -> the actual `amount` diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index df178ce0de..60f152037a 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -237,29 +237,25 @@ def warn_about_missing_opening_entries(self): title=_("Missing Opening Entries"), ) - def get_evaluated_components(self, total_working_hours: float = 0) -> frappe._dict: + def get_evaluated_components(self) -> frappe._dict: """Evaluate all salary structure components for this assignment and return fully-resolved rows the salary slip can consume directly. Earnings, deductions and employer contributions are evaluated in one shared pass (so a deduction formula can reference an earning abbr), each row carrying its full-cycle ``default_amount`` plus the flags the slip - needs. For timesheet-based structures the hourly-wage earning is set to - ``hour_rate * total_working_hours``. The slip consumes ``default_amount`` - directly and applies payment-days proration / tax on top (it re-evaluates - each formula once against its prorated context for the actual ``amount``). + needs. This is period-independent: the (period-dependent) timesheet wage + is built by the salary slip itself, not here. The slip consumes + ``default_amount`` directly and applies payment-days proration / tax on + top (it re-evaluates each formula once against its prorated context for + the actual ``amount``). """ _data, rows_by_type = self._evaluate_all_components() - timesheet_config = self.get_timesheet_config() - - if timesheet_config.based_on_timesheet and timesheet_config.timesheet_component: - self._apply_timesheet_wage(rows_by_type["earnings"], timesheet_config, flt(total_working_hours)) return frappe._dict( earnings=rows_by_type["earnings"], deductions=rows_by_type["deductions"], employer_contributions=rows_by_type["employer_contributions"], - timesheet_component=timesheet_config.timesheet_component, ) def get_timesheet_config(self) -> frappe._dict: @@ -418,56 +414,6 @@ def _evaluate_component_table(self, rows, data: frappe._dict) -> list: return resolved - def _apply_timesheet_wage( - self, earnings: list, timesheet_config: frappe._dict, total_working_hours: float - ) -> None: - """Add the timesheet wage earning (hour_rate * total_working_hours) as the - first earning. Any copy of the wage component declared in the structure - earnings is dropped first, mirroring legacy behaviour where the hourly-wage - row was added before the structure components.""" - wages_amount = flt(timesheet_config.hour_rate) * flt(total_working_hours) - earnings[:] = [r for r in earnings if r.salary_component != timesheet_config.timesheet_component] - earnings.insert(0, self._build_wage_row(timesheet_config.timesheet_component, wages_amount)) - - def _build_wage_row(self, component: str, amount: float) -> frappe._dict: - """Build a resolved earning row for a timesheet wage component that is not - declared in the structure earnings, from the Salary Component master.""" - comp = ( - frappe.db.get_value( - "Salary Component", - component, - ( - "name as salary_component", - "salary_component_abbr as abbr", - "depends_on_payment_days", - "do_not_include_in_total", - "do_not_include_in_accounts", - "is_tax_applicable", - "is_flexible_benefit", - "variable_based_on_taxable_salary", - "accrual_component", - "exempted_from_income_tax", - "statistical_component", - "deduct_full_tax_on_selected_payroll_date", - ), - as_dict=True, - cache=True, - ) - or frappe._dict() - ) - row = frappe._dict( - default_amount=amount, - amount=amount, - condition=None, - formula=None, - precision=2, - ) - for field in RESOLVED_ROW_FLAGS: - row[field] = comp.get(field) - row.amount_based_on_formula = 0 - row.salary_component = comp.get("salary_component") or component - return row - @frappe.whitelist() def are_opening_entries_required(self) -> bool: if not get_tax_component(self.salary_structure): diff --git a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py index 2392585bb9..ab7d2ee0c9 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py @@ -21,7 +21,7 @@ def _make_component(name, abbr, comp_type="Earning", **flags): class TestSalaryStructureAssignment(HRMSTestSuite): def test_ctc_and_annual_gross_exclude_statistical_and_include_employer(self): - """base=50000; gross = Basic (50000) only — statistical earning (1000) is + """base=50000; gross = Basic (50000) only - statistical earning (1000) is excluded; CTC = gross + employer contribution (6000).""" emp = make_employee("ssa_ctc_calc@test.com", company="_Test Company") @@ -88,7 +88,7 @@ def test_get_evaluated_components_does_not_mutate_cached_structure(self): self.assertTrue(all(not r.get("default_amount") for r in cached.earnings)) def test_do_not_include_in_total_earning_is_in_ctc_but_not_gross(self): - """A 'Do Not Include in Total' earning is part of CTC but not payable — it + """A 'Do Not Include in Total' earning is part of CTC but not payable - it must be excluded from annual_gross_earning yet included in ctc.""" emp = make_employee("ssa_dniit@test.com", company="_Test Company") @@ -162,3 +162,41 @@ def test_ctc_for_timesheet_structure_evaluates_base_driven_components(self): # base-driven earning evaluates normally; the variable hourly wage is excluded from CTC self.assertEqual(ssa.annual_gross_earning, 50000 * 12) self.assertEqual(ssa.ctc, 50000 * 12) + + def test_get_evaluated_components_excludes_timesheet_wage(self): + """SSA evaluation is period-independent: the timesheet wage (hour_rate * hours) + is built by the salary slip, not SSA. get_evaluated_components must return only + the structure's declared components, never an injected wage row.""" + emp = make_employee("ssa_ts_eval@test.com", company="_Test Company") + + _make_component("SSA TS2 Wage", "SSATS2W", "Earning") + _make_component("SSA TS2 Basic", "SSATS2B", "Earning", amount_based_on_formula=1, formula="base") + + earnings = [ + { + "salary_component": "SSA TS2 Basic", + "abbr": "SSATS2B", + "amount_based_on_formula": 1, + "formula": "base", + }, + ] + + make_salary_structure( + "SSA Timesheet Eval Structure", + "Monthly", + employee=emp, + company="_Test Company", + base=50000, + earnings=earnings, + deductions=[], + other_details={ + "salary_slip_based_on_timesheet": 1, + "hour_rate": 50, + "salary_component": "SSA TS2 Wage", + }, + ) + ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp}) + + components = [r.salary_component for r in ssa.get_evaluated_components().earnings] + self.assertNotIn("SSA TS2 Wage", components) + self.assertIn("SSA TS2 Basic", components) From 1a201a9cce49fee5ca0de01602cc297d26b44a94 Mon Sep 17 00:00:00 2001 From: Asmita Hase Date: Thu, 11 Jun 2026 03:26:52 +0530 Subject: [PATCH 9/9] fix: notes, variable names and redundant function passsing --- .../doctype/salary_slip/salary_slip.py | 73 +++++++++---------- .../salary_structure_assignment.py | 33 +++++---- hrms/payroll/utils.py | 16 ++-- 3 files changed, 57 insertions(+), 65 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index 3cb2c649f5..7b8f74d1ef 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -52,7 +52,6 @@ from hrms.payroll.utils import ( COMPONENT_EVAL_GLOBALS, _safe_eval, - get_component_abbr_map, get_component_eval_context, throw_error_message, ) @@ -61,7 +60,6 @@ # cache keys HOLIDAYS_BETWEEN_DATES = "holidays_between_dates" LEAVE_TYPE_MAP = "leave_type_map" -# NOTE: must match the key used in hrms.payroll.utils.get_component_abbr_map SALARY_COMPONENT_VALUES = "salary_component_values" TAX_COMPONENTS_BY_COMPANY = "tax_components_by_company" @@ -445,35 +443,39 @@ def get_emp_and_working_day_details(self) -> None: struct = self.check_sal_struct() if struct: + from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip + timesheet_config = self._get_ssa_doc().get_timesheet_config() self.salary_slip_based_on_timesheet = timesheet_config.based_on_timesheet - self._timesheet_component = timesheet_config.timesheet_component - self.set_time_sheet() - self.pull_sal_struct(timesheet_config) + if self.salary_slip_based_on_timesheet: + self._timesheet_component = timesheet_config.timesheet_component + self.set_time_sheet() + self.add_timesheet_earning_component(timesheet_config) + make_salary_slip(self.salary_structure, self) process_loan_interest_accrual_and_demand(self) def set_time_sheet(self): - if self.salary_slip_based_on_timesheet: - self.set("timesheets", []) + # caller (get_emp_and_working_day_details) gates this on salary_slip_based_on_timesheet + self.set("timesheets", []) - Timesheet = frappe.qb.DocType("Timesheet") - timesheets = ( - frappe.qb.from_(Timesheet) - .select(Timesheet.star) - .where( - (Timesheet.employee == self.employee) - & (Timesheet.start_date.between(self.start_date, self.end_date)) - & ( - (Timesheet.status == "Submitted") - | (Timesheet.status == "Billed") - | (Timesheet.status == "Partially Billed") - ) + Timesheet = frappe.qb.DocType("Timesheet") + timesheets = ( + frappe.qb.from_(Timesheet) + .select(Timesheet.star) + .where( + (Timesheet.employee == self.employee) + & (Timesheet.start_date.between(self.start_date, self.end_date)) + & ( + (Timesheet.status == "Submitted") + | (Timesheet.status == "Billed") + | (Timesheet.status == "Partially Billed") ) - ).run(as_dict=1) + ) + ).run(as_dict=1) - for data in timesheets: - self.append("timesheets", {"time_sheet": data.name, "working_hours": data.total_hours}) + for data in timesheets: + self.append("timesheets", {"time_sheet": data.name, "working_hours": data.total_hours}) def check_sal_struct(self): ss = frappe.qb.DocType("Salary Structure") @@ -517,18 +519,13 @@ def check_sal_struct(self): title=_("Salary Structure Missing"), ) - def pull_sal_struct(self, timesheet_config=None): - from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip - - if self.salary_slip_based_on_timesheet: - self.hour_rate = flt(timesheet_config.hour_rate) - self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) - self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0 - wages_amount = self.hour_rate * self.total_working_hours - - self.add_earning_for_hourly_wages(self, timesheet_config.timesheet_component, wages_amount) + def add_timesheet_earning_component(self, timesheet_config): + self.hour_rate = flt(timesheet_config.hour_rate) + self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) + self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0 + wages_amount = self.hour_rate * self.total_working_hours - make_salary_slip(self.salary_structure, self) + self.add_earning_for_hourly_wages(self, timesheet_config.timesheet_component, wages_amount) def add_earning_for_hourly_wages(self, doc, salary_component, amount): row_exists = False @@ -1271,7 +1268,7 @@ def add_structure_components(self, component_type): def add_structure_component(self, struct_row, component_type): # the timesheet wage component is added separately (hour_rate * hours) in - # pull_sal_struct, so skip it here to avoid double-adding + # add_timesheet_earning_component, so skip it here to avoid double-adding if self.salary_slip_based_on_timesheet and struct_row.salary_component == getattr( self, "_timesheet_component", None ): @@ -1349,11 +1346,7 @@ def get_data_for_eval(self): if not hasattr(self, "_salary_structure_assignment"): self.set_salary_structure_assignment() - data = get_component_eval_context( - self.employee, - self._salary_structure_assignment, - get_component_abbr_map(), - ) + data = get_component_eval_context(self.employee, self._salary_structure_assignment) # Overlay salary-slip fields (payment_days, gross_pay, start_date, …) last, so the # actual period context wins. Note: this means on a name collision a Salary Slip # field takes precedence over an Employee field (employee is layered earlier in @@ -1376,7 +1369,7 @@ def eval_condition_and_formula(self, struct_row, data): if condition and not _safe_eval(condition, self.whitelisted_globals, data): return None if struct_row.amount_based_on_formula and formula: - # struct_row is a resolved row (frappe._dict) from the SSA; precision is carried as an int + # struct_row is a evaluated row (frappe._dict) from the SSA; precision is carried as an int amount = flt(_safe_eval(formula, self.whitelisted_globals, data), struct_row.precision) if amount: data[struct_row.abbr] = amount diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 60f152037a..39ea6795f9 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -5,22 +5,21 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, flt, get_link_to_form, getdate +from frappe.utils import cint, date_diff, flt, get_link_to_form, getdate from hrms.payroll.doctype.payroll_period.payroll_period import get_payroll_period from hrms.payroll.doctype.salary_structure.salary_structure import validate_max_benefit_for_flexible_benefit from hrms.payroll.utils import ( COMPONENT_EVAL_GLOBALS, _safe_eval, - get_component_abbr_map, get_component_eval_context, sanitize_expression, throw_error_message, ) -# Fields copied from the salary structure component row onto each resolved row +# Fields copied from the salary structure component row onto each evaluated row # handed to the salary slip. The slip reads these to build/identify slip rows. -RESOLVED_ROW_FLAGS = ( +SALARY_COMPONENT_FLAGS = ( "salary_component", "abbr", "amount_based_on_formula", @@ -239,7 +238,7 @@ def warn_about_missing_opening_entries(self): def get_evaluated_components(self) -> frappe._dict: """Evaluate all salary structure components for this assignment and return - fully-resolved rows the salary slip can consume directly. + fully-evaluated rows the salary slip can consume directly. Earnings, deductions and employer contributions are evaluated in one shared pass (so a deduction formula can reference an earning abbr), each @@ -313,7 +312,7 @@ def calculate_ctc_and_gross(self) -> None: def _evaluate_all_components(self) -> tuple[frappe._dict, dict]: """Single shared-context pass over earnings -> deductions -> - employer_contributions. Returns the final context and resolved rows by + employer_contributions. Returns the final context and evaluated rows by type. Does not mutate the cached salary structure doc.""" salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure) data = self._get_component_eval_context() @@ -341,14 +340,18 @@ def _evaluate_all_components(self) -> tuple[frappe._dict, dict]: return data, rows_by_type def _get_component_eval_context(self) -> frappe._dict: - data = get_component_eval_context(self.employee, self.as_dict(), get_component_abbr_map()) + from hrms.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates + + data = get_component_eval_context(self.employee, self.as_dict()) # Full-cycle / preview seeding: SSA has no attendance, so it evaluates as a # full period -- payment_days == total_working_days (proration ratio 1) and no # LWP -- so formulas referencing slip-runtime fields resolve and yield - # full-cycle values (definitionally a for_preview slip's value). + # full-cycle values. Compute the period day-count the same way the salary + # slip's for_preview does (days in the period containing from_date). frequency = frappe.get_cached_value("Salary Structure", self.salary_structure, "payroll_frequency") - period_days = round(365 / PERIODS_PER_YEAR.get(frequency, 12)) + dates = get_start_end_dates(frequency, self.from_date, self.company) + period_days = date_diff(dates.end_date, dates.start_date) + 1 data.payment_days = period_days data.total_working_days = period_days data.leave_without_pay = 0 @@ -360,7 +363,7 @@ def _evaluate_component_table(self, rows, data: frappe._dict) -> list: with each component's full-cycle amount). Returns fresh ``frappe._dict`` rows (cache-safe copies). Raises a clear error on a bad formula/condition. Rows whose condition is falsey are skipped (not added to the slip).""" - resolved = [] + evaluated_components = [] for struct_row in rows: condition = sanitize_expression(struct_row.condition) formula = sanitize_expression(struct_row.formula) @@ -401,18 +404,18 @@ def _evaluate_component_table(self, rows, data: frappe._dict) -> list: data[struct_row.abbr] = default_amount - resolved_row = frappe._dict( + evaluated_component_row = frappe._dict( default_amount=default_amount, amount=amount, condition=condition, formula=formula, precision=struct_row.precision("amount"), ) - for field in RESOLVED_ROW_FLAGS: - resolved_row[field] = struct_row.get(field) - resolved.append(resolved_row) + for field in SALARY_COMPONENT_FLAGS: + evaluated_component_row[field] = struct_row.get(field) + evaluated_components.append(evaluated_component_row) - return resolved + return evaluated_components @frappe.whitelist() def are_opening_entries_required(self) -> bool: diff --git a/hrms/payroll/utils.py b/hrms/payroll/utils.py index 0b3eacd482..9ec4c4ddc6 100644 --- a/hrms/payroll/utils.py +++ b/hrms/payroll/utils.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.utils import ceil, floor, flt, get_first_day, get_last_day, get_link_to_form, getdate, rounded +from frappe.utils import ceil, floor, get_first_day, get_last_day, get_link_to_form, getdate, rounded def sanitize_expression(string: str | None = None) -> str | None: @@ -61,21 +61,17 @@ def _fetch_component_values(): return frappe.cache().get_value("salary_component_values", generator=_fetch_component_values) -def get_component_eval_context(employee: str, ssa_as_dict: dict, abbr_map: dict) -> frappe._dict: +def get_component_eval_context(employee: str, ssa_as_dict: dict) -> frappe._dict: """Build the base evaluation context for salary component formulas. - Merges component abbreviation defaults, SSA fields (base, variable) and - employee fields so that formulas can reference any of them by name. - SSA fields are applied before employee fields so that employee attributes - (employment_type, date_of_joining, …) take precedence over any same-named - SSA fields. base and variable are pinned last to guarantee correct values. + Merges component abbreviation defaults, Salary Structure Assignment fields + (base, variable, ...) and employee fields so that formulas can reference any + of them by name. """ data = frappe._dict() - data.update(abbr_map) + data.update(get_component_abbr_map()) data.update(ssa_as_dict) data.update(frappe.get_cached_doc("Employee", employee).as_dict()) - data["base"] = flt(ssa_as_dict.get("base") or 0) - data["variable"] = flt(ssa_as_dict.get("variable") or 0) return data