hr_lite 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -1
- data/README.md +312 -99
- data/app/assets/javascripts/hr_lite/confirm.js +31 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +16 -1
- data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/employees_controller.rb +36 -12
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +15 -8
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
- data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +17 -2
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
- data/app/controllers/hr_lite/org_controller.rb +6 -1
- data/app/controllers/hr_lite/resignations_controller.rb +6 -1
- data/app/helpers/hr_lite/application_helper.rb +2 -12
- data/app/jobs/hr_lite/application_job.rb +5 -0
- data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
- data/app/models/concerns/hr_lite/audited.rb +11 -4
- data/app/models/hr_lite/appraisal.rb +9 -1
- data/app/models/hr_lite/audit_log.rb +11 -0
- data/app/models/hr_lite/comp_off_request.rb +5 -15
- data/app/models/hr_lite/designation_change.rb +18 -1
- data/app/models/hr_lite/employee_profile.rb +5 -1
- data/app/models/hr_lite/leave_balance.rb +51 -5
- data/app/models/hr_lite/leave_request.rb +9 -1
- data/app/models/hr_lite/leave_type.rb +13 -3
- data/app/models/hr_lite/payroll_run.rb +39 -10
- data/app/models/hr_lite/regularization_request.rb +8 -0
- data/app/models/hr_lite/resignation.rb +27 -0
- data/app/models/hr_lite/salary_slip.rb +7 -3
- data/app/services/hr_lite/attendance_puncher.rb +8 -1
- data/app/services/hr_lite/calculators/esi.rb +7 -4
- data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
- data/app/services/hr_lite/calculators/tds.rb +8 -1
- data/app/services/hr_lite/leave_day_counter.rb +9 -4
- data/app/services/hr_lite/overview_query.rb +12 -1
- data/app/services/hr_lite/payroll_run_processor.rb +5 -0
- data/app/services/hr_lite/slip_builder.rb +54 -8
- data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
- data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
- data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
- data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
- data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
- data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
- data/app/views/hr_lite/career/show.html.erb +7 -1
- data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
- data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
- data/app/views/layouts/hr_lite/application.html.erb +42 -4
- data/config/routes.rb +1 -1
- data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
- data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
- data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
- data/lib/hr_lite/amount_in_words.rb +15 -2
- data/lib/hr_lite/money.rb +18 -0
- data/lib/hr_lite/notifications.rb +23 -8
- data/lib/hr_lite/statutory_rate_card.rb +6 -1
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +5 -1
- metadata +5 -1
|
@@ -7,7 +7,9 @@ module HrLite
|
|
|
7
7
|
class EmployeesController < LeadershipController
|
|
8
8
|
def index
|
|
9
9
|
@profiles = paginate(EmployeeProfile.includes(:user).order(:employee_code))
|
|
10
|
-
|
|
10
|
+
# One pluck instead of an EXISTS query per employee.
|
|
11
|
+
profiled = EmployeeProfile.pluck(:user_id).to_set
|
|
12
|
+
@users_without_profile = HrLite.employees.reject { |u| profiled.include?(u.id) }
|
|
11
13
|
@pending_resignations = Resignation.pending.includes(:user).recent_first
|
|
12
14
|
end
|
|
13
15
|
|
|
@@ -24,17 +26,26 @@ module HrLite
|
|
|
24
26
|
def create
|
|
25
27
|
new_login = params.dig(:employee_profile, :new_user_email).present?
|
|
26
28
|
@profile = EmployeeProfile.new(profile_params)
|
|
29
|
+
saved = false
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
# One transaction: a profile that fails validation used to leave the
|
|
32
|
+
# freshly-created login committed — a role-bearing account with no HR
|
|
33
|
+
# profile, invisible on every HR screen.
|
|
34
|
+
ActiveRecord::Base.transaction do
|
|
35
|
+
if new_login
|
|
36
|
+
user = HrLite.config.onboard_user.call(
|
|
37
|
+
name: params.dig(:employee_profile, :new_user_name).to_s,
|
|
38
|
+
email: params.dig(:employee_profile, :new_user_email).to_s.strip,
|
|
39
|
+
password: params.dig(:employee_profile, :new_user_password).to_s
|
|
40
|
+
)
|
|
41
|
+
@profile.user_id = user.id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
saved = @profile.save
|
|
45
|
+
raise ActiveRecord::Rollback unless saved
|
|
35
46
|
end
|
|
36
47
|
|
|
37
|
-
if
|
|
48
|
+
if saved
|
|
38
49
|
if new_login
|
|
39
50
|
invite_url = HrLite.config.invite_url_for&.call(@profile.user)
|
|
40
51
|
Notifications.publish(
|
|
@@ -66,7 +77,7 @@ module HrLite
|
|
|
66
77
|
|
|
67
78
|
def update
|
|
68
79
|
@profile = EmployeeProfile.find(params[:id])
|
|
69
|
-
if @profile.update(
|
|
80
|
+
if @profile.update(update_profile_params)
|
|
70
81
|
redirect_to admin_employee_path(@profile), notice: "Employee profile updated."
|
|
71
82
|
else
|
|
72
83
|
render :edit, status: :unprocessable_entity
|
|
@@ -77,7 +88,9 @@ module HrLite
|
|
|
77
88
|
# access via the host hook. Audited via the profile update.
|
|
78
89
|
def offboard
|
|
79
90
|
@profile = EmployeeProfile.find(params[:id])
|
|
80
|
-
|
|
91
|
+
# `.to_date` on a raw param raises Date::Error and 500s; the
|
|
92
|
+
# controller's own parser exists for exactly this.
|
|
93
|
+
exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
|
|
81
94
|
|
|
82
95
|
@profile.update!(date_of_exit: exit_date)
|
|
83
96
|
begin
|
|
@@ -87,6 +100,8 @@ module HrLite
|
|
|
87
100
|
end
|
|
88
101
|
redirect_to admin_employee_path(@profile),
|
|
89
102
|
notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
|
|
103
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
104
|
+
redirect_to admin_employee_path(@profile), alert: e.record.errors.full_messages.to_sentence
|
|
90
105
|
end
|
|
91
106
|
|
|
92
107
|
private
|
|
@@ -95,9 +110,18 @@ module HrLite
|
|
|
95
110
|
params.require(:employee_profile).permit(
|
|
96
111
|
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
97
112
|
:date_of_exit, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
98
|
-
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions
|
|
113
|
+
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
|
|
114
|
+
:fy_opening_gross, :fy_opening_tds
|
|
99
115
|
)
|
|
100
116
|
end
|
|
117
|
+
|
|
118
|
+
# user_id says WHOSE profile this is, and salary structures, appraisals
|
|
119
|
+
# and slips all key off it independently. Repointing an existing profile
|
|
120
|
+
# hands one person's PAN, bank details and pay history to another, so it
|
|
121
|
+
# is a create-time field only.
|
|
122
|
+
def update_profile_params
|
|
123
|
+
profile_params.except(:user_id)
|
|
124
|
+
end
|
|
101
125
|
end
|
|
102
126
|
end
|
|
103
127
|
end
|
|
@@ -2,8 +2,7 @@ module HrLite
|
|
|
2
2
|
module Admin
|
|
3
3
|
class LeaveBalancesController < BaseController
|
|
4
4
|
def index
|
|
5
|
-
year =
|
|
6
|
-
@year = year.between?(2000, 2100) ? year : LeaveYear.current_key
|
|
5
|
+
@year = sanitized_year
|
|
7
6
|
@types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
|
|
8
7
|
@employees = HrLite.employees
|
|
9
8
|
end
|
|
@@ -12,7 +11,9 @@ module HrLite
|
|
|
12
11
|
def adjust
|
|
13
12
|
user = HrLite.user_klass.find(params[:user_id])
|
|
14
13
|
type = LeaveType.find(params[:leave_type_id])
|
|
15
|
-
|
|
14
|
+
# Unsanitised, a missing param wrote the adjustment into leave year 0,
|
|
15
|
+
# where no screen can ever show it.
|
|
16
|
+
year = sanitized_year
|
|
16
17
|
delta = BigDecimal(params[:delta].to_s)
|
|
17
18
|
note = params[:note].to_s.strip
|
|
18
19
|
|
|
@@ -20,10 +21,7 @@ module HrLite
|
|
|
20
21
|
return redirect_to admin_leave_balances_path(year: year), alert: "A note is required."
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
balance = LeaveBalance.
|
|
24
|
-
balance.adjustment += delta
|
|
25
|
-
balance.adjustment_note = [ balance.adjustment_note.presence, "#{delta.to_f} — #{note}" ].compact.join("; ")
|
|
26
|
-
balance.save!
|
|
24
|
+
balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
|
|
27
25
|
|
|
28
26
|
AuditLog.create!(
|
|
29
27
|
actor: hr_current_user, action: "adjust",
|
|
@@ -34,7 +32,16 @@ module HrLite
|
|
|
34
32
|
redirect_to admin_leave_balances_path(year: year),
|
|
35
33
|
notice: "Balance adjusted for #{HrLite.display_name(user)}."
|
|
36
34
|
rescue ArgumentError
|
|
37
|
-
|
|
35
|
+
# Keep the admin on the year they were looking at.
|
|
36
|
+
redirect_to admin_leave_balances_path(year: sanitized_year),
|
|
37
|
+
alert: "Enter a valid adjustment number."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def sanitized_year
|
|
43
|
+
year = params[:year].to_i
|
|
44
|
+
year.between?(2000, 2100) ? year : LeaveYear.current_key
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -24,6 +24,22 @@ module HrLite
|
|
|
24
24
|
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# LeaveRequest#cancellable_by? has always allowed an admin to call off
|
|
28
|
+
# approved future leave, but no route reached it — the only cancel action
|
|
29
|
+
# was the employee's own, scoped to their own rows. A trip called off
|
|
30
|
+
# while the person was unreachable, or after they were offboarded, could
|
|
31
|
+
# not be released at all, and the quota stayed spent.
|
|
32
|
+
def cancel
|
|
33
|
+
request = LeaveRequest.find(params[:id])
|
|
34
|
+
if request.cancellable_by?(hr_current_user)
|
|
35
|
+
request.cancel!(actor: hr_current_user)
|
|
36
|
+
redirect_to admin_leave_requests_path, notice: "Leave cancelled — the balance is released."
|
|
37
|
+
else
|
|
38
|
+
redirect_to admin_leave_request_path(request),
|
|
39
|
+
alert: "Only pending leave, or approved leave that has not started, can be cancelled."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
def reject
|
|
28
44
|
request = LeaveRequest.find(params[:id])
|
|
29
45
|
note = params[:decision_note].to_s.strip
|
|
@@ -5,13 +5,25 @@ module HrLite
|
|
|
5
5
|
resignation = Resignation.find(params[:id])
|
|
6
6
|
resignation.accept!(
|
|
7
7
|
actor: hr_current_user,
|
|
8
|
-
|
|
8
|
+
# `.to_date` on a raw param raises Date::Error and 500s; the
|
|
9
|
+
# controller's own parser exists for exactly this.
|
|
10
|
+
last_day: params[:last_day].presence && parse_date_param(params[:last_day]),
|
|
9
11
|
note: params[:note]
|
|
10
12
|
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
notice = if resignation.exit_date_recorded?
|
|
14
|
+
"Resignation accepted — exit date recorded on the profile."
|
|
15
|
+
else
|
|
16
|
+
"Resignation accepted. No employee profile exists for " \
|
|
17
|
+
"#{hr_display_name(resignation.user)}, so no exit date was recorded — " \
|
|
18
|
+
"create the profile to clip attendance and payroll."
|
|
19
|
+
end
|
|
20
|
+
redirect_to admin_employees_path, notice: notice
|
|
21
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
22
|
+
# A rejected exit date used to be reported as "not pending", which was
|
|
23
|
+
# simply the wrong reason: the transaction rolls back either way.
|
|
24
|
+
message = resignation.reload.pending? ? e.record.errors.full_messages.to_sentence
|
|
25
|
+
: "Only pending resignations can be accepted."
|
|
26
|
+
redirect_to admin_employees_path, alert: message
|
|
15
27
|
end
|
|
16
28
|
end
|
|
17
29
|
end
|
|
@@ -20,11 +20,26 @@ module HrLite
|
|
|
20
20
|
tds_override = params.dig(:salary_slip, :tds_override).presence
|
|
21
21
|
profile = EmployeeProfile.find_by(user_id: slip.user_id)
|
|
22
22
|
structure = SalaryStructure.effective_for(slip.user, run.period_month)
|
|
23
|
+
if structure.nil?
|
|
24
|
+
return redirect_to admin_salary_slip_path(slip),
|
|
25
|
+
alert: "No salary structure is effective for #{run.label} — add one first."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
lop = lop_override && BigDecimal(lop_override)
|
|
29
|
+
tds = tds_override && BigDecimal(tds_override)
|
|
30
|
+
# Unbounded overrides are a money bug, not a typo: a negative LOP pays
|
|
31
|
+
# for days never worked, one above the month zeroes the person out.
|
|
32
|
+
if lop && !lop.between?(0, slip.days_in_month)
|
|
33
|
+
return redirect_to admin_salary_slip_path(slip),
|
|
34
|
+
alert: "LOP days must be between 0 and #{slip.days_in_month}."
|
|
35
|
+
end
|
|
36
|
+
if tds&.negative?
|
|
37
|
+
return redirect_to admin_salary_slip_path(slip), alert: "TDS cannot be negative."
|
|
38
|
+
end
|
|
23
39
|
|
|
24
40
|
attributes = SlipBuilder.call(
|
|
25
41
|
run: run, user: slip.user, structure: structure, profile: profile,
|
|
26
|
-
lop_override:
|
|
27
|
-
tds_override: tds_override && BigDecimal(tds_override)
|
|
42
|
+
lop_override: lop, tds_override: tds
|
|
28
43
|
)
|
|
29
44
|
slip.update!(attributes)
|
|
30
45
|
|
|
@@ -3,7 +3,12 @@ module HrLite
|
|
|
3
3
|
# Money tier: salary structures, payroll, slips, appraisals and
|
|
4
4
|
# promotions. Only config.superadmin_emails — ordinary leadership can
|
|
5
5
|
# govern policy and people but never sees another person's pay.
|
|
6
|
+
# The money tier stands on its own rather than layering on leadership: a
|
|
7
|
+
# payroll operator does not have to also govern people and policy.
|
|
8
|
+
# Inheriting the leadership check locked a configured superadmin who was
|
|
9
|
+
# absent from leadership_emails out of payroll entirely.
|
|
6
10
|
class SuperadminController < LeadershipController
|
|
11
|
+
skip_before_action :require_hr_leadership!
|
|
7
12
|
before_action :require_hr_superadmin!
|
|
8
13
|
|
|
9
14
|
private
|
|
@@ -10,8 +10,13 @@ module HrLite
|
|
|
10
10
|
@by_user = profiles.index_by(&:user_id)
|
|
11
11
|
build_tree(profiles)
|
|
12
12
|
@own_profile = @by_user[hr_current_user.id]
|
|
13
|
+
# STOPS at the first exited manager rather than skipping past them. The
|
|
14
|
+
# L-labels are positional, so dropping a link from the middle promoted
|
|
15
|
+
# everyone above it — the card then called a skip-level manager your L1,
|
|
16
|
+
# contradicting the tree below, which drops exited managers entirely and
|
|
17
|
+
# renders their reports as roots.
|
|
13
18
|
exited = EmployeeProfile.where(date_of_exit: ...Date.current).pluck(:user_id).to_set
|
|
14
|
-
@own_chain = (@own_profile&.reporting_chain || []).
|
|
19
|
+
@own_chain = (@own_profile&.reporting_chain || []).take_while { |boss| !exited.include?(boss.id) }
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
private
|
|
@@ -4,7 +4,12 @@ module HrLite
|
|
|
4
4
|
class ResignationsController < ApplicationController
|
|
5
5
|
def show
|
|
6
6
|
@resignation = own.recent_first.first
|
|
7
|
-
|
|
7
|
+
# No form once one is pending OR already accepted: an agreed exit date is
|
|
8
|
+
# not something to re-file, and accepting a second one would overwrite
|
|
9
|
+
# the date payroll and attendance already clip to.
|
|
10
|
+
unless @resignation&.pending? || @resignation&.accepted?
|
|
11
|
+
@new_resignation = Resignation.new(proposed_last_day: Date.current + 30)
|
|
12
|
+
end
|
|
8
13
|
end
|
|
9
14
|
|
|
10
15
|
def create
|
|
@@ -71,19 +71,9 @@ module HrLite
|
|
|
71
71
|
render "hr_lite/shared/pagination"
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Delegates to the PORO so the host-rendered slip PDF formats identically.
|
|
74
75
|
def hrl_money(amount)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
whole, fraction = HrLite::Money.round2(amount).to_s("F").split(".")
|
|
78
|
-
sign = whole.start_with?("-") ? "-" : ""
|
|
79
|
-
whole = whole.delete_prefix("-")
|
|
80
|
-
# Indian digit grouping: last three digits together, then pairs
|
|
81
|
-
# (12,34,567 — not the western 1,234,567).
|
|
82
|
-
if whole.length > 3
|
|
83
|
-
head = whole[0..-4].reverse.scan(/\d{1,2}/).join(",").reverse
|
|
84
|
-
whole = "#{head},#{whole[-3..]}"
|
|
85
|
-
end
|
|
86
|
-
"#{sign}#{HrLite.config.currency_symbol}#{whole}.#{fraction.ljust(2, '0')}"
|
|
76
|
+
HrLite::Money.format(amount)
|
|
87
77
|
end
|
|
88
78
|
|
|
89
79
|
# Indian-system amount in words for the slip footer. Delegates to the
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
module HrLite
|
|
2
|
+
# Parent of the engine's jobs. It existed but nothing inherited from it, so
|
|
3
|
+
# no retry policy applied anywhere: a digest or a rollover that hit a
|
|
4
|
+
# dropped connection was simply lost for that day — or for that year.
|
|
2
5
|
class ApplicationJob < ActiveJob::Base
|
|
6
|
+
retry_on ActiveRecord::ConnectionNotEstablished, ActiveRecord::Deadlocked,
|
|
7
|
+
wait: :polynomially_longer, attempts: 5
|
|
3
8
|
end
|
|
4
9
|
end
|
|
@@ -4,7 +4,7 @@ module HrLite
|
|
|
4
4
|
# carry-forward into the new year's balance rows. Idempotent — a balance
|
|
5
5
|
# whose carry has already been written is never touched again (manual
|
|
6
6
|
# adjustments stay safe).
|
|
7
|
-
class LeaveYearRolloverJob <
|
|
7
|
+
class LeaveYearRolloverJob < ApplicationJob
|
|
8
8
|
queue_as :default
|
|
9
9
|
|
|
10
10
|
def perform(year: nil)
|
|
@@ -3,14 +3,17 @@ module HrLite
|
|
|
3
3
|
# payroll from attendance and the policy, then tell leadership it is
|
|
4
4
|
# waiting for review. Publishing stays a deliberate human action —
|
|
5
5
|
# the system prepares, people approve.
|
|
6
|
-
class PayrollAutoDraftJob <
|
|
6
|
+
class PayrollAutoDraftJob < ApplicationJob
|
|
7
7
|
queue_as :default
|
|
8
8
|
|
|
9
9
|
def perform(month: Date.current.prev_month.beginning_of_month)
|
|
10
10
|
return unless EmployeeProfile.active_for(month).exists?
|
|
11
11
|
|
|
12
12
|
run = PayrollRun.find_or_create_by!(period_month: month)
|
|
13
|
-
|
|
13
|
+
# Only ever drafts an UNTOUCHED run. Accepting `review` as well meant a
|
|
14
|
+
# re-run recomputed a month somebody was already reviewing and told
|
|
15
|
+
# leadership all over again that it was waiting for them.
|
|
16
|
+
return unless run.draft? && run.salary_slips.none?
|
|
14
17
|
|
|
15
18
|
run.compute!(actor: nil)
|
|
16
19
|
Notifications.publish(
|
|
@@ -9,10 +9,14 @@ module HrLite
|
|
|
9
9
|
SKIPPED_ATTRIBUTES = %w[updated_at created_at].freeze
|
|
10
10
|
REDACTED = "[changed]".freeze
|
|
11
11
|
|
|
12
|
+
# *_commit, not *_create/_update/_destroy: these callbacks write an audit
|
|
13
|
+
# row and email leadership a diff. Fired inside the transaction they
|
|
14
|
+
# announce changes that then roll back — a failed onboarding used to mail
|
|
15
|
+
# out a profile diff for a profile that was never saved.
|
|
12
16
|
included do
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
after_create_commit { hr_lite_audit!("create") }
|
|
18
|
+
after_update_commit { hr_lite_audit!("update") }
|
|
19
|
+
after_destroy_commit { hr_lite_audit!("destroy") }
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
private
|
|
@@ -63,7 +67,10 @@ module HrLite
|
|
|
63
67
|
"policy.changed",
|
|
64
68
|
title: "#{actor_name.presence || 'Someone'} #{log.action}d #{subject}",
|
|
65
69
|
body: nil,
|
|
66
|
-
|
|
70
|
+
# policy.changed fans out to leadership_emails, which is a WIDER list
|
|
71
|
+
# than the money tier. Leadership may know an appraisal changed; the
|
|
72
|
+
# ratings and review text inside it are not theirs to read.
|
|
73
|
+
diff: log.money_tier? ? nil : log.audited_changes
|
|
67
74
|
)
|
|
68
75
|
end
|
|
69
76
|
end
|
|
@@ -43,7 +43,15 @@ module HrLite
|
|
|
43
43
|
body: rating ? "Rating: #{rating}/5" : nil,
|
|
44
44
|
path: "/appraisals/#{id}",
|
|
45
45
|
bell_to: [ user ],
|
|
46
|
-
email_to: [ user ]
|
|
46
|
+
email_to: [ user ],
|
|
47
|
+
# Leadership gets the fact, named, pointing somewhere they can open —
|
|
48
|
+
# but not the rating: appraisal content is money-tier and this list is
|
|
49
|
+
# wider than that tier.
|
|
50
|
+
leadership: {
|
|
51
|
+
title: "#{HrLite.display_name(user)}'s appraisal for #{period_label} was shared",
|
|
52
|
+
body: nil,
|
|
53
|
+
path: "/admin/employees"
|
|
54
|
+
}
|
|
47
55
|
)
|
|
48
56
|
true
|
|
49
57
|
end
|
|
@@ -8,7 +8,18 @@ module HrLite
|
|
|
8
8
|
|
|
9
9
|
validates :action, :subject_type, :subject_id, presence: true
|
|
10
10
|
|
|
11
|
+
# Subjects whose CONTENTS belong to the money tier. Salary structures
|
|
12
|
+
# encrypt their amounts so the diff is already redacted, but appraisal
|
|
13
|
+
# ratings and review text are plain columns — they must not reach the
|
|
14
|
+
# leadership audit screen or the policy.changed email.
|
|
15
|
+
MONEY_TIER_TYPES = %w[HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure].freeze
|
|
16
|
+
|
|
11
17
|
scope :recent, -> { order(created_at: :desc) }
|
|
18
|
+
scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
|
|
19
|
+
|
|
20
|
+
def money_tier?
|
|
21
|
+
MONEY_TIER_TYPES.include?(subject_type)
|
|
22
|
+
end
|
|
12
23
|
|
|
13
24
|
def readonly?
|
|
14
25
|
persisted?
|
|
@@ -106,21 +106,11 @@ module HrLite
|
|
|
106
106
|
# balance. The balance row is locked for the increment; two admins
|
|
107
107
|
# approving two different requests for one person cannot lose an update.
|
|
108
108
|
def credit_balance!(type)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
rescue ActiveRecord::RecordNotUnique
|
|
115
|
-
balance = LeaveBalance.for(user, type, year)
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
balance.with_lock do
|
|
119
|
-
balance.adjustment += credit_days
|
|
120
|
-
balance.adjustment_note = [ balance.adjustment_note.presence,
|
|
121
|
-
"+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})" ].compact.join("; ")
|
|
122
|
-
balance.save!
|
|
123
|
-
end
|
|
109
|
+
LeaveBalance.adjust!(
|
|
110
|
+
user, type, LeaveYear.current_key,
|
|
111
|
+
delta: credit_days,
|
|
112
|
+
note: "+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})"
|
|
113
|
+
)
|
|
124
114
|
end
|
|
125
115
|
|
|
126
116
|
def transition!(new_status, actor, note)
|
|
@@ -27,7 +27,17 @@ module HrLite
|
|
|
27
27
|
self.from_designation ||= EmployeeProfile.find_by(user_id: user_id)&.designation
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Only the newest row owns the profile's designation. Backfilling an older
|
|
31
|
+
# promotion used to overwrite the current title and push the stale one to
|
|
32
|
+
# the host as well. (A future-dated promotion still applies on save — that
|
|
33
|
+
# is how sharing an appraisal announces the new role.)
|
|
34
|
+
def latest_by_effective_date?
|
|
35
|
+
self.class.where(user_id: user_id).timeline.first&.id == id
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
def apply_to_profile
|
|
39
|
+
return unless latest_by_effective_date?
|
|
40
|
+
|
|
31
41
|
profile = EmployeeProfile.find_by(user_id: user_id)
|
|
32
42
|
profile&.update!(designation: to_designation)
|
|
33
43
|
|
|
@@ -45,7 +55,14 @@ module HrLite
|
|
|
45
55
|
body: "Effective #{effective_date.strftime('%d %b %Y')}.",
|
|
46
56
|
path: "/career",
|
|
47
57
|
bell_to: [ user ],
|
|
48
|
-
email_to: [ user ]
|
|
58
|
+
email_to: [ user ],
|
|
59
|
+
# "/career" is the reader's OWN career page, so a leadership copy of
|
|
60
|
+
# this used to link them to their own record instead of the person the
|
|
61
|
+
# promotion is about.
|
|
62
|
+
leadership: {
|
|
63
|
+
title: "#{HrLite.display_name(user)} — new role: #{to_designation}",
|
|
64
|
+
path: "/admin/employees"
|
|
65
|
+
}
|
|
49
66
|
)
|
|
50
67
|
end
|
|
51
68
|
end
|
|
@@ -13,7 +13,11 @@ module HrLite
|
|
|
13
13
|
belongs_to :manager, class_name: HrLite.config.user_class, optional: true
|
|
14
14
|
|
|
15
15
|
encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
|
|
16
|
-
|
|
16
|
+
# fy_opening_*: income already paid this financial year that this install
|
|
17
|
+
# did not run — a previous employer, or the months before payroll was
|
|
18
|
+
# switched on here. Feeds the TDS projection so a mid-year start does not
|
|
19
|
+
# read as zero income for the months it cannot see.
|
|
20
|
+
encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
|
|
17
21
|
|
|
18
22
|
TAX_REGIMES = %w[new old].freeze
|
|
19
23
|
|
|
@@ -19,6 +19,34 @@ module HrLite
|
|
|
19
19
|
find_or_initialize_by(user_id: user.id, leave_type: leave_type, year: year)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Persisted and row-locked so concurrent writers serialize here. The
|
|
23
|
+
# create sits in its own savepoint: on PostgreSQL a failed INSERT aborts
|
|
24
|
+
# the enclosing transaction, so a plain rescue could never run.
|
|
25
|
+
def self.lock_for(user, leave_type, year)
|
|
26
|
+
attributes = { user_id: user.id, leave_type_id: leave_type.id, year: year }
|
|
27
|
+
balance = find_by(attributes)
|
|
28
|
+
balance ||= begin
|
|
29
|
+
transaction(requires_new: true) { create!(attributes) }
|
|
30
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
31
|
+
find_by!(attributes)
|
|
32
|
+
end
|
|
33
|
+
balance.lock!
|
|
34
|
+
balance
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The single write path for the stored adjustment. Both callers — the
|
|
38
|
+
# comp-off credit and the admin correction — increment the same column,
|
|
39
|
+
# and read-modify-write without the lock lost one of them.
|
|
40
|
+
def self.adjust!(user, leave_type, year, delta:, note:)
|
|
41
|
+
transaction do
|
|
42
|
+
balance = lock_for(user, leave_type, year)
|
|
43
|
+
balance.adjustment += delta
|
|
44
|
+
balance.adjustment_note = [ balance.adjustment_note.presence, note ].compact.join("; ")
|
|
45
|
+
balance.save!
|
|
46
|
+
balance
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
22
50
|
def entitled(as_of: Date.current)
|
|
23
51
|
return Float::INFINITY if leave_type.unlimited?
|
|
24
52
|
|
|
@@ -30,7 +58,12 @@ module HrLite
|
|
|
30
58
|
requests = LeaveRequest.approved
|
|
31
59
|
.where(user_id: user_id, leave_type_id: leave_type_id)
|
|
32
60
|
.where(start_date: range)
|
|
33
|
-
|
|
61
|
+
# ONE calendar for the whole leave year, reused across every request.
|
|
62
|
+
# Building one per request meant a Holiday query and a Setting query
|
|
63
|
+
# each — and the admin balances grid reads this cell for every employee
|
|
64
|
+
# times every leave type.
|
|
65
|
+
calendar = WorkingCalendar.new(range)
|
|
66
|
+
requests.sum { |request| LeaveDayCounter.count(request, calendar: calendar) }
|
|
34
67
|
end
|
|
35
68
|
|
|
36
69
|
def available(as_of: Date.current)
|
|
@@ -46,28 +79,41 @@ module HrLite
|
|
|
46
79
|
def accrued_base(as_of)
|
|
47
80
|
range = LeaveYear.range(year)
|
|
48
81
|
start = accrual_start(range)
|
|
49
|
-
|
|
82
|
+
finish = accrual_end(range)
|
|
83
|
+
return 0 if start.nil? || start > finish
|
|
50
84
|
|
|
51
85
|
monthly_rate = leave_type.annual_quota / 12
|
|
52
86
|
months =
|
|
53
87
|
if leave_type.accrual == "monthly"
|
|
54
|
-
cap = [ as_of,
|
|
88
|
+
cap = [ as_of, finish ].min
|
|
55
89
|
start > cap ? 0 : months_between(start, cap)
|
|
56
90
|
else
|
|
57
|
-
months_between(start,
|
|
91
|
+
months_between(start, finish)
|
|
58
92
|
end
|
|
59
93
|
monthly_rate * months
|
|
60
94
|
end
|
|
61
95
|
|
|
62
96
|
# nil when the person joins only after this leave year ends.
|
|
63
97
|
def accrual_start(range)
|
|
64
|
-
doj =
|
|
98
|
+
doj = employment_window.first
|
|
65
99
|
return range.first if doj.nil? || doj <= range.first
|
|
66
100
|
|
|
67
101
|
start = doj.day <= 15 ? doj.beginning_of_month : doj.next_month.beginning_of_month
|
|
68
102
|
start > range.last ? nil : start
|
|
69
103
|
end
|
|
70
104
|
|
|
105
|
+
# Quota stops accruing on the last working day — entitlement used to keep
|
|
106
|
+
# growing for months after someone had left.
|
|
107
|
+
def accrual_end(range)
|
|
108
|
+
exit_date = employment_window.last
|
|
109
|
+
exit_date ? [ exit_date, range.last ].min : range.last
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def employment_window
|
|
113
|
+
@employment_window ||= EmployeeProfile.where(user_id: user_id)
|
|
114
|
+
.pick(:date_of_joining, :date_of_exit) || [ nil, nil ]
|
|
115
|
+
end
|
|
116
|
+
|
|
71
117
|
def months_between(from, to)
|
|
72
118
|
(to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
|
|
73
119
|
end
|
|
@@ -44,6 +44,11 @@ module HrLite
|
|
|
44
44
|
def approve!(actor:, note: nil)
|
|
45
45
|
insufficient = false
|
|
46
46
|
transition!("approved", actor, note) do
|
|
47
|
+
# Serialize on the BALANCE row. `transition!`'s own lock is on this
|
|
48
|
+
# request, and two pending requests are two different rows — so both
|
|
49
|
+
# approvals could read the same untouched balance and overdraw it.
|
|
50
|
+
LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
|
|
51
|
+
|
|
47
52
|
if insufficient_balance_now?
|
|
48
53
|
insufficient = true
|
|
49
54
|
raise ActiveRecord::Rollback
|
|
@@ -120,7 +125,10 @@ module HrLite
|
|
|
120
125
|
def insufficient_balance_now?
|
|
121
126
|
return false if leave_type.unlimited?
|
|
122
127
|
|
|
123
|
-
|
|
128
|
+
# Same as-of date the create-time check used. Reading it as of TODAY
|
|
129
|
+
# meant a request validated against the balance it would have on the
|
|
130
|
+
# leave dates could never be approved on a monthly-accrual type.
|
|
131
|
+
LeaveDayCounter.count(self) > balance.available(as_of: start_date)
|
|
124
132
|
end
|
|
125
133
|
|
|
126
134
|
def notify_requested
|
|
@@ -13,6 +13,9 @@ module HrLite
|
|
|
13
13
|
validates :annual_quota, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
14
14
|
validates :carry_forward_cap, numericality: { greater_than_or_equal_to: 0 }
|
|
15
15
|
validate :single_comp_off_type
|
|
16
|
+
# Retiring the flagged type hands the flag back, so a replacement can take
|
|
17
|
+
# it and `comp_off_type` (which resolves through `active`) keeps working.
|
|
18
|
+
before_save :release_comp_off_when_retired
|
|
16
19
|
|
|
17
20
|
scope :active, -> { where(active: true).order(:position, :id) }
|
|
18
21
|
|
|
@@ -28,12 +31,19 @@ module HrLite
|
|
|
28
31
|
private
|
|
29
32
|
|
|
30
33
|
# Approvals credit into THE comp-off type — two flagged types would
|
|
31
|
-
# make the credit target position-order roulette.
|
|
34
|
+
# make the credit target position-order roulette. Scoped to ACTIVE rows
|
|
35
|
+
# so it agrees with `comp_off_type`, which resolves through `active`:
|
|
36
|
+
# counting a retired type here made the flag impossible to move to its
|
|
37
|
+
# replacement, and comp-off approval then failed for everyone.
|
|
32
38
|
def single_comp_off_type
|
|
33
|
-
return unless comp_off
|
|
39
|
+
return unless comp_off && active
|
|
34
40
|
|
|
35
|
-
clash = self.class.where(comp_off: true).where.not(id: id)
|
|
41
|
+
clash = self.class.active.where(comp_off: true).where.not(id: id)
|
|
36
42
|
errors.add(:comp_off, "is already set on #{clash.first.name}") if clash.exists?
|
|
37
43
|
end
|
|
44
|
+
|
|
45
|
+
def release_comp_off_when_retired
|
|
46
|
+
self.comp_off = false if comp_off && !active
|
|
47
|
+
end
|
|
38
48
|
end
|
|
39
49
|
end
|