hr_lite 0.4.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -1
  3. data/README.md +312 -99
  4. data/app/assets/javascripts/hr_lite/confirm.js +31 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
  6. data/app/controllers/hr_lite/admin/appraisals_controller.rb +1 -1
  7. data/app/controllers/hr_lite/admin/attendances_controller.rb +16 -1
  8. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
  9. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/employees_controller.rb +37 -13
  11. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +15 -8
  12. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  13. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +1 -1
  14. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  15. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +18 -3
  16. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +1 -1
  17. data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
  18. data/app/controllers/hr_lite/admin/superadmin_controller.rb +21 -0
  19. data/app/controllers/hr_lite/application_controller.rb +5 -1
  20. data/app/controllers/hr_lite/org_controller.rb +6 -1
  21. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  22. data/app/helpers/hr_lite/application_helper.rb +7 -13
  23. data/app/jobs/hr_lite/application_job.rb +5 -0
  24. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  25. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  26. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  27. data/app/models/concerns/hr_lite/audited.rb +11 -4
  28. data/app/models/hr_lite/appraisal.rb +9 -1
  29. data/app/models/hr_lite/audit_log.rb +11 -0
  30. data/app/models/hr_lite/comp_off_request.rb +5 -15
  31. data/app/models/hr_lite/designation_change.rb +18 -1
  32. data/app/models/hr_lite/employee_profile.rb +26 -1
  33. data/app/models/hr_lite/leave_balance.rb +51 -5
  34. data/app/models/hr_lite/leave_request.rb +9 -1
  35. data/app/models/hr_lite/leave_type.rb +13 -3
  36. data/app/models/hr_lite/payroll_run.rb +39 -10
  37. data/app/models/hr_lite/regularization_request.rb +8 -0
  38. data/app/models/hr_lite/resignation.rb +27 -0
  39. data/app/models/hr_lite/salary_slip.rb +7 -3
  40. data/app/models/hr_lite/setting.rb +2 -0
  41. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  42. data/app/services/hr_lite/calculators/esi.rb +7 -4
  43. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  44. data/app/services/hr_lite/calculators/tds.rb +8 -1
  45. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  46. data/app/services/hr_lite/overview_query.rb +12 -1
  47. data/app/services/hr_lite/payroll_run_processor.rb +5 -0
  48. data/app/services/hr_lite/slip_builder.rb +54 -8
  49. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  50. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  51. data/app/views/hr_lite/admin/employees/_form.html.erb +30 -3
  52. data/app/views/hr_lite/admin/employees/index.html.erb +3 -0
  53. data/app/views/hr_lite/admin/employees/show.html.erb +34 -18
  54. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  55. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  56. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  57. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  58. data/app/views/hr_lite/admin/settings/edit.html.erb +7 -0
  59. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  60. data/app/views/hr_lite/career/show.html.erb +7 -1
  61. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  62. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  63. data/app/views/layouts/hr_lite/application.html.erb +47 -4
  64. data/config/routes.rb +1 -1
  65. data/db/migrate/20260720094454_add_employee_code_prefix_to_hr_lite_settings.rb +7 -0
  66. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  67. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  68. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  69. data/lib/hr_lite/amount_in_words.rb +15 -2
  70. data/lib/hr_lite/configuration.rb +8 -0
  71. data/lib/hr_lite/money.rb +18 -0
  72. data/lib/hr_lite/notifications.rb +23 -8
  73. data/lib/hr_lite/statutory_rate_card.rb +6 -1
  74. data/lib/hr_lite/version.rb +1 -1
  75. data/lib/hr_lite.rb +9 -1
  76. metadata +7 -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
- @users_without_profile = HrLite.employees.reject { |u| EmployeeProfile.exists?(user_id: u.id) }
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
- if new_login
29
- user = HrLite.config.onboard_user.call(
30
- name: params.dig(:employee_profile, :new_user_name).to_s,
31
- email: params.dig(:employee_profile, :new_user_email).to_s.strip,
32
- password: params.dig(:employee_profile, :new_user_password).to_s
33
- )
34
- @profile.user_id = user.id
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 @profile.save
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(profile_params)
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
- exit_date = params[:date_of_exit].presence&.to_date || Date.current
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,17 +100,28 @@ 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
93
108
 
94
109
  def profile_params
95
110
  params.require(:employee_profile).permit(
96
- :user_id, :manager_id, :employee_code, :designation, :date_of_birth, :date_of_joining,
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 = params[:year].to_i
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
- year = params[:year].to_i
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.for(user, type, year)
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
- redirect_to admin_leave_balances_path, alert: "Enter a valid adjustment number."
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
@@ -1,6 +1,6 @@
1
1
  module HrLite
2
2
  module Admin
3
- class PayrollRunsController < LeadershipController
3
+ class PayrollRunsController < SuperadminController
4
4
  def index
5
5
  @runs = paginate(PayrollRun.recent_first)
6
6
  end
@@ -5,13 +5,25 @@ module HrLite
5
5
  resignation = Resignation.find(params[:id])
6
6
  resignation.accept!(
7
7
  actor: hr_current_user,
8
- last_day: params[:last_day].presence&.to_date,
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
- redirect_to admin_employees_path,
12
- notice: "Resignation accepted — exit date recorded on the profile."
13
- rescue ActiveRecord::RecordInvalid
14
- redirect_to admin_employees_path, alert: "Only pending resignations can be accepted."
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
@@ -1,6 +1,6 @@
1
1
  module HrLite
2
2
  module Admin
3
- class SalarySlipsController < LeadershipController
3
+ class SalarySlipsController < SuperadminController
4
4
  def show
5
5
  @slip = SalarySlip.includes(:payroll_run).find(params[:id])
6
6
  @profile = @slip.user_profile
@@ -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: lop_override && BigDecimal(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
 
@@ -1,6 +1,6 @@
1
1
  module HrLite
2
2
  module Admin
3
- class SalaryStructuresController < LeadershipController
3
+ class SalaryStructuresController < SuperadminController
4
4
  before_action :set_profile
5
5
 
6
6
  def new
@@ -7,7 +7,7 @@ module HrLite
7
7
 
8
8
  def update
9
9
  @setting = Setting.instance
10
- if @setting.update(params.require(:setting).permit(:weekend_policy))
10
+ if @setting.update(params.require(:setting).permit(:weekend_policy, :employee_code_prefix))
11
11
  redirect_to edit_admin_setting_path, notice: "Settings saved."
12
12
  else
13
13
  render :edit, status: :unprocessable_entity
@@ -0,0 +1,21 @@
1
+ module HrLite
2
+ module Admin
3
+ # Money tier: salary structures, payroll, slips, appraisals and
4
+ # promotions. Only config.superadmin_emails — ordinary leadership can
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.
10
+ class SuperadminController < LeadershipController
11
+ skip_before_action :require_hr_leadership!
12
+ before_action :require_hr_superadmin!
13
+
14
+ private
15
+
16
+ def require_hr_superadmin!
17
+ hr_access_denied unless hr_superadmin?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -10,7 +10,7 @@ module HrLite
10
10
  before_action :hr_set_current_actor
11
11
  around_action :hr_use_time_zone
12
12
 
13
- helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_display_name
13
+ helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
14
14
 
15
15
  private
16
16
 
@@ -26,6 +26,10 @@ module HrLite
26
26
  HrLite.admin?(hr_current_user)
27
27
  end
28
28
 
29
+ def hr_superadmin?
30
+ HrLite.superadmin?(hr_current_user)
31
+ end
32
+
29
33
  def hr_leadership?
30
34
  HrLite.leadership?(hr_current_user)
31
35
  end
@@ -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 || []).reject { |boss| exited.include?(boss.id) }
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
- @new_resignation = Resignation.new(proposed_last_day: Date.current + 30) unless @resignation&.pending?
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
@@ -20,11 +20,15 @@ module HrLite
20
20
 
21
21
  LEADERSHIP_NAV_ITEMS = [
22
22
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
23
- { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
24
23
  { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
25
24
  { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
26
25
  ].freeze
27
26
 
27
+ # Money tier — only config.superadmin_emails see these.
28
+ SUPERADMIN_NAV_ITEMS = [
29
+ { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
30
+ ].freeze
31
+
28
32
  # Only items whose routes exist yet (the nav grows with each phase).
29
33
  def hrl_nav_items(items)
30
34
  items.select { |item| hr_lite_route?(item[:path]) }
@@ -67,19 +71,9 @@ module HrLite
67
71
  render "hr_lite/shared/pagination"
68
72
  end
69
73
 
74
+ # Delegates to the PORO so the host-rendered slip PDF formats identically.
70
75
  def hrl_money(amount)
71
- return "—" if amount.nil?
72
-
73
- whole, fraction = HrLite::Money.round2(amount).to_s("F").split(".")
74
- sign = whole.start_with?("-") ? "-" : ""
75
- whole = whole.delete_prefix("-")
76
- # Indian digit grouping: last three digits together, then pairs
77
- # (12,34,567 — not the western 1,234,567).
78
- if whole.length > 3
79
- head = whole[0..-4].reverse.scan(/\d{1,2}/).join(",").reverse
80
- whole = "#{head},#{whole[-3..]}"
81
- end
82
- "#{sign}#{HrLite.config.currency_symbol}#{whole}.#{fraction.ljust(2, '0')}"
76
+ HrLite::Money.format(amount)
83
77
  end
84
78
 
85
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
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  # Morning leadership email: who's out, what's pending, what's flagged.
3
3
  # Sends nothing on a quiet day.
4
- class DailyDigestJob < ActiveJob::Base
4
+ class DailyDigestJob < ApplicationJob
5
5
  queue_as :default
6
6
 
7
7
  def perform(date: Date.current)
@@ -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 < ActiveJob::Base
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 < ActiveJob::Base
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
- return unless run.draft? || run.review?
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
- after_create { hr_lite_audit!("create") }
14
- after_update { hr_lite_audit!("update") }
15
- after_destroy { hr_lite_audit!("destroy") }
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
- diff: log.audited_changes
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
- year = LeaveYear.current_key
110
- balance = LeaveBalance.for(user, type, year)
111
- if balance.new_record?
112
- begin
113
- balance.save!
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
- encrypted_money :declared_annual_deductions
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
 
@@ -21,6 +25,8 @@ module HrLite
21
25
  # config.onboard_user; never persisted, never audited).
22
26
  attr_accessor :new_user_name, :new_user_email, :new_user_password
23
27
 
28
+ before_validation :assign_employee_code, on: :create
29
+
24
30
  validates :employee_code, presence: true, uniqueness: true
25
31
  validates :user_id, uniqueness: true
26
32
  validates :date_of_joining, presence: true
@@ -89,6 +95,25 @@ module HrLite
89
95
 
90
96
  private
91
97
 
98
+ # Codes are system-assigned: Settings prefix + zero-padded next number
99
+ # (EMP001, EMP002, ...). Scans the highest existing suffix for the
100
+ # CURRENT prefix so changing the prefix restarts a fresh sequence
101
+ # without colliding with history.
102
+ def assign_employee_code
103
+ return if employee_code.present?
104
+
105
+ prefix = Setting.instance.employee_code_prefix
106
+ last = self.class.where("employee_code LIKE ?", "#{sanitize_sql_like(prefix)}%")
107
+ .pluck(:employee_code)
108
+ .filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
109
+ .max || 0
110
+ self.employee_code = format("%s%03d", prefix, last + 1)
111
+ end
112
+
113
+ def sanitize_sql_like(value)
114
+ self.class.sanitize_sql_like(value)
115
+ end
116
+
92
117
  # A newly-assigned manager must be a real, still-employed staff member.
93
118
  def manager_is_active_staff
94
119
  return if manager_id.nil?