hr_lite 0.1.0
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 +7 -0
- data/CHANGELOG.md +33 -0
- data/MIT-LICENSE +20 -0
- data/README.md +166 -0
- data/Rakefile +3 -0
- data/app/assets/javascripts/hr_lite/geo_punch.js +61 -0
- data/app/assets/javascripts/hr_lite/mention.js +100 -0
- data/app/assets/stylesheets/hr_lite/application.css +15 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +239 -0
- data/app/controllers/hr_lite/admin/appraisals_controller.rb +74 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +79 -0
- data/app/controllers/hr_lite/admin/audit_logs_controller.rb +9 -0
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -0
- data/app/controllers/hr_lite/admin/designation_changes_controller.rb +33 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +53 -0
- data/app/controllers/hr_lite/admin/holidays_controller.rb +70 -0
- data/app/controllers/hr_lite/admin/leadership_controller.rb +18 -0
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/leave_types_controller.rb +54 -0
- data/app/controllers/hr_lite/admin/office_locations_controller.rb +46 -0
- data/app/controllers/hr_lite/admin/overview_controller.rb +12 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +93 -0
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +44 -0
- data/app/controllers/hr_lite/admin/salary_structures_controller.rb +49 -0
- data/app/controllers/hr_lite/admin/settings_controller.rb +18 -0
- data/app/controllers/hr_lite/application_controller.rb +76 -0
- data/app/controllers/hr_lite/appraisals_controller.rb +18 -0
- data/app/controllers/hr_lite/attendance_controller.rb +37 -0
- data/app/controllers/hr_lite/calendar_controller.rb +20 -0
- data/app/controllers/hr_lite/career_controller.rb +9 -0
- data/app/controllers/hr_lite/employee_profiles_controller.rb +9 -0
- data/app/controllers/hr_lite/holidays_controller.rb +9 -0
- data/app/controllers/hr_lite/home_controller.rb +7 -0
- data/app/controllers/hr_lite/kudos_controller.rb +36 -0
- data/app/controllers/hr_lite/leave_balances_controller.rb +11 -0
- data/app/controllers/hr_lite/leave_requests_controller.rb +56 -0
- data/app/controllers/hr_lite/salary_slips_controller.rb +39 -0
- data/app/controllers/hr_lite/users_controller.rb +9 -0
- data/app/helpers/hr_lite/application_helper.rb +105 -0
- data/app/jobs/hr_lite/application_job.rb +4 -0
- data/app/jobs/hr_lite/daily_digest_job.rb +33 -0
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +29 -0
- data/app/mailers/hr_lite/application_mailer.rb +5 -0
- data/app/mailers/hr_lite/event_mailer.rb +33 -0
- data/app/models/concerns/hr_lite/audited.rb +70 -0
- data/app/models/concerns/hr_lite/encrypted_money.rb +32 -0
- data/app/models/hr_lite/application_record.rb +5 -0
- data/app/models/hr_lite/appraisal.rb +89 -0
- data/app/models/hr_lite/attendance_record.rb +41 -0
- data/app/models/hr_lite/audit_log.rb +17 -0
- data/app/models/hr_lite/designation_change.rb +52 -0
- data/app/models/hr_lite/employee_profile.rb +72 -0
- data/app/models/hr_lite/holiday.rb +18 -0
- data/app/models/hr_lite/kudo.rb +54 -0
- data/app/models/hr_lite/kudo_mention.rb +8 -0
- data/app/models/hr_lite/leave_balance.rb +42 -0
- data/app/models/hr_lite/leave_request.rb +207 -0
- data/app/models/hr_lite/leave_type.rb +22 -0
- data/app/models/hr_lite/office_location.rb +21 -0
- data/app/models/hr_lite/payroll_run.rb +112 -0
- data/app/models/hr_lite/salary_slip.rb +78 -0
- data/app/models/hr_lite/salary_structure.rb +49 -0
- data/app/models/hr_lite/setting.rb +14 -0
- data/app/services/hr_lite/attendance_puncher.rb +103 -0
- data/app/services/hr_lite/attendance_summary.rb +98 -0
- data/app/services/hr_lite/calculators/esi.rb +25 -0
- data/app/services/hr_lite/calculators/pf.rb +32 -0
- data/app/services/hr_lite/calculators/professional_tax.rb +20 -0
- data/app/services/hr_lite/calculators/proration.rb +30 -0
- data/app/services/hr_lite/calculators/tds.rb +74 -0
- data/app/services/hr_lite/day_status.rb +49 -0
- data/app/services/hr_lite/leave_day_counter.rb +24 -0
- data/app/services/hr_lite/overview_query.rb +39 -0
- data/app/services/hr_lite/payroll_run_processor.rb +46 -0
- data/app/services/hr_lite/pdf_renderer.rb +23 -0
- data/app/services/hr_lite/slip_builder.rb +106 -0
- data/app/services/hr_lite/working_calendar.rb +41 -0
- data/app/views/hr_lite/admin/appraisals/_form.html.erb +29 -0
- data/app/views/hr_lite/admin/appraisals/edit.html.erb +6 -0
- data/app/views/hr_lite/admin/appraisals/new.html.erb +6 -0
- data/app/views/hr_lite/admin/attendances/index.html.erb +49 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +55 -0
- data/app/views/hr_lite/admin/audit_logs/index.html.erb +39 -0
- data/app/views/hr_lite/admin/designation_changes/new.html.erb +19 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +40 -0
- data/app/views/hr_lite/admin/employees/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/index.html.erb +40 -0
- data/app/views/hr_lite/admin/employees/new.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +75 -0
- data/app/views/hr_lite/admin/holidays/index.html.erb +59 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +58 -0
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +35 -0
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +39 -0
- data/app/views/hr_lite/admin/leave_types/_form.html.erb +27 -0
- data/app/views/hr_lite/admin/leave_types/edit.html.erb +10 -0
- data/app/views/hr_lite/admin/leave_types/index.html.erb +39 -0
- data/app/views/hr_lite/admin/leave_types/new.html.erb +3 -0
- data/app/views/hr_lite/admin/office_locations/_form.html.erb +18 -0
- data/app/views/hr_lite/admin/office_locations/edit.html.erb +10 -0
- data/app/views/hr_lite/admin/office_locations/index.html.erb +30 -0
- data/app/views/hr_lite/admin/office_locations/new.html.erb +3 -0
- data/app/views/hr_lite/admin/overview/index.html.erb +72 -0
- data/app/views/hr_lite/admin/payroll_runs/index.html.erb +33 -0
- data/app/views/hr_lite/admin/payroll_runs/new.html.erb +20 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +76 -0
- data/app/views/hr_lite/admin/salary_slips/show.html.erb +42 -0
- data/app/views/hr_lite/admin/salary_structures/_form.html.erb +25 -0
- data/app/views/hr_lite/admin/salary_structures/edit.html.erb +6 -0
- data/app/views/hr_lite/admin/salary_structures/new.html.erb +6 -0
- data/app/views/hr_lite/admin/settings/edit.html.erb +18 -0
- data/app/views/hr_lite/appraisals/index.html.erb +22 -0
- data/app/views/hr_lite/appraisals/show.html.erb +32 -0
- data/app/views/hr_lite/attendance/_month_grid.html.erb +41 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +41 -0
- data/app/views/hr_lite/attendance/show.html.erb +22 -0
- data/app/views/hr_lite/calendar/show.html.erb +45 -0
- data/app/views/hr_lite/career/show.html.erb +49 -0
- data/app/views/hr_lite/employee_profiles/show.html.erb +22 -0
- data/app/views/hr_lite/event_mailer/event.html.erb +10 -0
- data/app/views/hr_lite/event_mailer/event.text.erb +10 -0
- data/app/views/hr_lite/event_mailer/leadership.html.erb +27 -0
- data/app/views/hr_lite/event_mailer/leadership.text.erb +16 -0
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +12 -0
- data/app/views/hr_lite/holidays/index.html.erb +30 -0
- data/app/views/hr_lite/home/index.html.erb +25 -0
- data/app/views/hr_lite/kudos/_kudo.html.erb +17 -0
- data/app/views/hr_lite/kudos/index.html.erb +39 -0
- data/app/views/hr_lite/leave_balances/index.html.erb +19 -0
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +17 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +37 -0
- data/app/views/hr_lite/leave_requests/new.html.erb +36 -0
- data/app/views/hr_lite/leave_requests/show.html.erb +25 -0
- data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +48 -0
- data/app/views/hr_lite/salary_slips/index.html.erb +29 -0
- data/app/views/hr_lite/salary_slips/pdf.html.erb +72 -0
- data/app/views/hr_lite/salary_slips/show.html.erb +12 -0
- data/app/views/hr_lite/shared/_form_errors.html.erb +10 -0
- data/app/views/hr_lite/shared/_pagination.html.erb +11 -0
- data/app/views/layouts/hr_lite/application.html.erb +56 -0
- data/app/views/layouts/hr_lite/mailer.html.erb +30 -0
- data/app/views/layouts/hr_lite/mailer.text.erb +6 -0
- data/app/views/layouts/hr_lite/pdf.html.erb +26 -0
- data/config/routes.rb +57 -0
- data/db/migrate/20260718190726_create_hr_lite_kudos.rb +14 -0
- data/db/migrate/20260718190727_create_hr_lite_kudo_mentions.rb +12 -0
- data/db/migrate/20260718190728_create_hr_lite_audit_logs.rb +15 -0
- data/db/migrate/20260718192342_create_hr_lite_office_locations.rb +13 -0
- data/db/migrate/20260718192343_create_hr_lite_attendance_records.rb +27 -0
- data/db/migrate/20260718193045_create_hr_lite_holidays.rb +14 -0
- data/db/migrate/20260718193046_create_hr_lite_leave_types.rb +20 -0
- data/db/migrate/20260718193047_create_hr_lite_leave_balances.rb +17 -0
- data/db/migrate/20260718193048_create_hr_lite_leave_requests.rb +23 -0
- data/db/migrate/20260718193049_create_hr_lite_settings.rb +10 -0
- data/db/migrate/20260718194534_create_hr_lite_employee_profiles.rb +27 -0
- data/db/migrate/20260718194535_create_hr_lite_salary_structures.rb +23 -0
- data/db/migrate/20260718194536_create_hr_lite_payroll_runs.rb +20 -0
- data/db/migrate/20260718194537_create_hr_lite_salary_slips.rb +28 -0
- data/db/migrate/20260718195953_create_hr_lite_appraisals.rb +22 -0
- data/db/migrate/20260718195954_create_hr_lite_designation_changes.rb +17 -0
- data/lib/generators/hr_lite/install/install_generator.rb +29 -0
- data/lib/generators/hr_lite/install/templates/AFTER_INSTALL +35 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +45 -0
- data/lib/hr_lite/configuration.rb +60 -0
- data/lib/hr_lite/current.rb +7 -0
- data/lib/hr_lite/engine.rb +39 -0
- data/lib/hr_lite/geo.rb +22 -0
- data/lib/hr_lite/mention_parser.rb +18 -0
- data/lib/hr_lite/money.rb +33 -0
- data/lib/hr_lite/notifications.rb +99 -0
- data/lib/hr_lite/seeds.rb +42 -0
- data/lib/hr_lite/statutory_rate_card.rb +60 -0
- data/lib/hr_lite/version.rb +3 -0
- data/lib/hr_lite.rb +104 -0
- data/lib/tasks/hr_lite_tasks.rake +8 -0
- metadata +250 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
class LeaveRequest < ApplicationRecord
|
|
3
|
+
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
6
|
+
belongs_to :leave_type
|
|
7
|
+
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
8
|
+
|
|
9
|
+
validates :start_date, :end_date, presence: true
|
|
10
|
+
validates :status, inclusion: { in: STATUSES }
|
|
11
|
+
|
|
12
|
+
with_options on: :create do
|
|
13
|
+
validate :end_after_start
|
|
14
|
+
validate :same_calendar_year
|
|
15
|
+
validate :half_day_single_day_only
|
|
16
|
+
validate :consumes_at_least_half_a_day
|
|
17
|
+
validate :no_overlap_with_own_requests
|
|
18
|
+
validate :no_punch_conflict
|
|
19
|
+
validate :sufficient_balance
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
before_validation :cache_days_count, on: :create
|
|
23
|
+
after_create :notify_requested
|
|
24
|
+
|
|
25
|
+
scope :pending, -> { where(status: "pending") }
|
|
26
|
+
scope :approved, -> { where(status: "approved") }
|
|
27
|
+
scope :active_on, ->(date) { approved.where(start_date: ..date).where(end_date: date..) }
|
|
28
|
+
scope :overlapping_range, ->(from, to) { where(start_date: ..to).where(end_date: from..) }
|
|
29
|
+
scope :recent_first, -> { order(start_date: :desc, id: :desc) }
|
|
30
|
+
|
|
31
|
+
STATUSES.each do |s|
|
|
32
|
+
define_method("#{s}?") { status == s }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def paid?
|
|
36
|
+
leave_type.paid
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# --- transitions -------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Returns false (leaving the request pending) when the balance no longer
|
|
42
|
+
# covers it — the re-check runs inside the row lock so two concurrent
|
|
43
|
+
# approvals cannot overdraw one balance.
|
|
44
|
+
def approve!(actor:, note: nil)
|
|
45
|
+
insufficient = false
|
|
46
|
+
transition!("approved", actor, note) do
|
|
47
|
+
if insufficient_balance_now?
|
|
48
|
+
insufficient = true
|
|
49
|
+
raise ActiveRecord::Rollback
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
return false if insufficient
|
|
53
|
+
|
|
54
|
+
notify_decision("Leave approved")
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reject!(actor:, note:)
|
|
59
|
+
transition!("rejected", actor, note)
|
|
60
|
+
notify_decision("Leave rejected")
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Owner may cancel while pending, or an approved future leave (quota
|
|
65
|
+
# returns automatically because `used` is computed). Admins may cancel
|
|
66
|
+
# any pending/approved future leave.
|
|
67
|
+
def cancellable_by?(actor)
|
|
68
|
+
return false unless pending? || (approved? && start_date > Date.current)
|
|
69
|
+
|
|
70
|
+
user_id == actor.id || HrLite.admin?(actor) || HrLite.leadership?(actor)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cancel!(actor:)
|
|
74
|
+
was_approved = approved?
|
|
75
|
+
self.status = "cancelled"
|
|
76
|
+
self.decided_by_id = actor.id
|
|
77
|
+
self.decided_at = Time.current
|
|
78
|
+
save!
|
|
79
|
+
|
|
80
|
+
Notifications.publish(
|
|
81
|
+
"leave.cancelled",
|
|
82
|
+
title: "#{HrLite.display_name(user)} cancelled #{was_approved ? 'approved ' : ''}leave " \
|
|
83
|
+
"(#{date_range_label})",
|
|
84
|
+
path: "/admin/leave_requests",
|
|
85
|
+
bell_to: HrLite.admin_users
|
|
86
|
+
)
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def date_range_label
|
|
91
|
+
if start_date == end_date
|
|
92
|
+
"#{start_date.strftime('%d %b')}#{half_day ? ' (half day)' : ''}"
|
|
93
|
+
else
|
|
94
|
+
"#{start_date.strftime('%d %b')} – #{end_date.strftime('%d %b')}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def balance
|
|
99
|
+
LeaveBalance.for(user, leave_type, start_date.year)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def transition!(new_status, actor, note)
|
|
105
|
+
with_lock do
|
|
106
|
+
raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
|
|
107
|
+
|
|
108
|
+
yield if block_given?
|
|
109
|
+
self.status = new_status
|
|
110
|
+
self.decided_by_id = actor.id
|
|
111
|
+
self.decided_at = Time.current
|
|
112
|
+
self.decision_note = note
|
|
113
|
+
save!
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# This request is still pending here, so balance.used excludes it:
|
|
118
|
+
# approving is valid iff the request fits the remaining balance.
|
|
119
|
+
def insufficient_balance_now?
|
|
120
|
+
return false if leave_type.unlimited?
|
|
121
|
+
|
|
122
|
+
LeaveDayCounter.count(self) > balance.available
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def notify_requested
|
|
126
|
+
Notifications.publish(
|
|
127
|
+
"leave.requested",
|
|
128
|
+
title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
|
|
129
|
+
body: reason.presence,
|
|
130
|
+
path: "/admin/leave_requests/#{id}",
|
|
131
|
+
bell_to: HrLite.admin_users
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def notify_decision(title)
|
|
136
|
+
Notifications.publish(
|
|
137
|
+
status == "approved" ? "leave.approved" : "leave.rejected",
|
|
138
|
+
title: "#{title} — #{leave_type.name} (#{date_range_label})",
|
|
139
|
+
body: decision_note.presence,
|
|
140
|
+
path: "/leave_requests/#{id}",
|
|
141
|
+
bell_to: [ user ],
|
|
142
|
+
email_to: [ user ]
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def cache_days_count
|
|
147
|
+
self.days_count = LeaveDayCounter.count(self) if start_date && end_date
|
|
148
|
+
self.days_count ||= 0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def end_after_start
|
|
152
|
+
return unless start_date && end_date
|
|
153
|
+
|
|
154
|
+
errors.add(:end_date, "must be on or after the start date") if end_date < start_date
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def same_calendar_year
|
|
158
|
+
return unless start_date && end_date
|
|
159
|
+
|
|
160
|
+
if start_date.year != end_date.year
|
|
161
|
+
errors.add(:base, "Split requests at the year boundary")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def half_day_single_day_only
|
|
166
|
+
errors.add(:half_day, "is only for single-day requests") if half_day && start_date != end_date
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def consumes_at_least_half_a_day
|
|
170
|
+
return unless start_date && end_date
|
|
171
|
+
return if errors.any?
|
|
172
|
+
|
|
173
|
+
if LeaveDayCounter.count(self) <= 0
|
|
174
|
+
errors.add(:base, "Selected dates are all holidays or weekends")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def no_overlap_with_own_requests
|
|
179
|
+
return unless start_date && end_date
|
|
180
|
+
|
|
181
|
+
clash = self.class.where(user_id: user_id, status: %w[pending approved])
|
|
182
|
+
.where.not(id: id)
|
|
183
|
+
.overlapping_range(start_date, end_date)
|
|
184
|
+
errors.add(:base, "You already have leave overlapping these dates") if clash.exists?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A full-day leave over a day that already has a check-in makes no
|
|
188
|
+
# sense; a half-day alongside a punch is legitimate (worked half).
|
|
189
|
+
def no_punch_conflict
|
|
190
|
+
return unless start_date && end_date
|
|
191
|
+
return if half_day
|
|
192
|
+
|
|
193
|
+
punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
|
|
194
|
+
.where.not(check_in_at: nil)
|
|
195
|
+
errors.add(:base, "You have marked attendance in this period") if punched.exists?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def sufficient_balance
|
|
199
|
+
return unless start_date && end_date && leave_type
|
|
200
|
+
return if leave_type.unlimited? || errors.any?
|
|
201
|
+
|
|
202
|
+
if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
|
|
203
|
+
errors.add(:base, "Not enough #{leave_type.name} balance")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
class LeaveType < ApplicationRecord
|
|
3
|
+
include Audited
|
|
4
|
+
|
|
5
|
+
ACCRUALS = %w[yearly_upfront monthly].freeze
|
|
6
|
+
|
|
7
|
+
has_many :leave_requests, dependent: :restrict_with_error
|
|
8
|
+
has_many :leave_balances, dependent: :restrict_with_error
|
|
9
|
+
|
|
10
|
+
validates :name, :code, presence: true, uniqueness: true
|
|
11
|
+
validates :color, format: { with: /\A#\h{6}\z/ }
|
|
12
|
+
validates :accrual, inclusion: { in: ACCRUALS }
|
|
13
|
+
validates :annual_quota, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
14
|
+
validates :carry_forward_cap, numericality: { greater_than_or_equal_to: 0 }
|
|
15
|
+
|
|
16
|
+
scope :active, -> { where(active: true).order(:position, :id) }
|
|
17
|
+
|
|
18
|
+
def unlimited?
|
|
19
|
+
annual_quota.nil?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
class OfficeLocation < ApplicationRecord
|
|
3
|
+
include Audited
|
|
4
|
+
|
|
5
|
+
validates :name, presence: true
|
|
6
|
+
validates :lat, presence: true, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
|
|
7
|
+
validates :lng, presence: true, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }
|
|
8
|
+
validates :radius_m, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
9
|
+
|
|
10
|
+
scope :active, -> { where(active: true) }
|
|
11
|
+
|
|
12
|
+
def self.covering?(lat, lng)
|
|
13
|
+
active.any? { |office| Geo.distance_m(office.lat, office.lng, lat, lng) <= office.radius_m }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# For flag notes: "1.2 km from Head Office". Nil when no offices exist.
|
|
17
|
+
def self.nearest(lat, lng)
|
|
18
|
+
active.min_by { |office| Geo.distance_m(office.lat, office.lng, lat, lng) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Monthly run lifecycle: draft -> processing -> review -> finalized ->
|
|
3
|
+
# published (terminal). Compute is synchronous (pure-Ruby math over a
|
|
4
|
+
# small team; the seam for a background job is one perform_later away).
|
|
5
|
+
class PayrollRun < ApplicationRecord
|
|
6
|
+
STATUSES = %w[draft processing review finalized published].freeze
|
|
7
|
+
|
|
8
|
+
has_many :salary_slips, dependent: :destroy
|
|
9
|
+
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
10
|
+
belongs_to :finalized_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
belongs_to :published_by, class_name: HrLite.config.user_class, optional: true
|
|
12
|
+
|
|
13
|
+
validates :period_month, presence: true, uniqueness: true
|
|
14
|
+
validates :status, inclusion: { in: STATUSES }
|
|
15
|
+
validate :period_is_first_of_month
|
|
16
|
+
before_destroy :draft_only_destroy
|
|
17
|
+
|
|
18
|
+
scope :recent_first, -> { order(period_month: :desc) }
|
|
19
|
+
|
|
20
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
21
|
+
|
|
22
|
+
def editable?
|
|
23
|
+
draft? || processing? || review?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def label
|
|
27
|
+
period_month.strftime("%B %Y")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def compute!(actor:)
|
|
31
|
+
raise_unless %w[draft review]
|
|
32
|
+
|
|
33
|
+
update!(status: "processing")
|
|
34
|
+
PayrollRunProcessor.call(self)
|
|
35
|
+
update!(status: "review", processed_at: Time.current)
|
|
36
|
+
true
|
|
37
|
+
rescue => e
|
|
38
|
+
update_columns(status: "draft") # rubocop:disable Rails/SkipsModelValidations
|
|
39
|
+
raise e
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def finalize!(actor:)
|
|
43
|
+
raise_unless %w[review]
|
|
44
|
+
raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
|
|
45
|
+
|
|
46
|
+
update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
|
|
47
|
+
Notifications.publish(
|
|
48
|
+
"payroll.finalized",
|
|
49
|
+
title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
|
|
50
|
+
path: "/admin/payroll_runs/#{id}"
|
|
51
|
+
)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unlock!(actor:)
|
|
56
|
+
raise_unless %w[finalized]
|
|
57
|
+
update!(status: "review")
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def publish!(actor:)
|
|
62
|
+
raise_unless %w[finalized]
|
|
63
|
+
update!(status: "published", published_at: Time.current, published_by_id: actor.id)
|
|
64
|
+
|
|
65
|
+
slips = salary_slips.includes(:user).to_a
|
|
66
|
+
Notifications.publish(
|
|
67
|
+
"payroll.published",
|
|
68
|
+
title: "Your salary slip for #{label} is ready",
|
|
69
|
+
body: "Open Earthly HR to view or download it.",
|
|
70
|
+
path: "/salary_slips",
|
|
71
|
+
bell_to: slips.map(&:user),
|
|
72
|
+
email_to: slips.map(&:user)
|
|
73
|
+
)
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Ruby-side aggregates (amounts are encrypted — no SQL sums).
|
|
78
|
+
def total_gross = sum_slips(:gross_earnings)
|
|
79
|
+
def total_deductions = sum_slips(:total_deductions)
|
|
80
|
+
def total_net = sum_slips(:net_pay)
|
|
81
|
+
|
|
82
|
+
def total_employer_cost
|
|
83
|
+
salary_slips.sum(BigDecimal(0)) do |slip|
|
|
84
|
+
slip.employer_costs_hash.values.sum(BigDecimal(0)) { |v| Money.d(v) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def sum_slips(attribute)
|
|
91
|
+
salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def raise_unless(allowed)
|
|
95
|
+
raise ActiveRecord::RecordInvalid.new(self), "invalid transition" unless allowed.include?(status)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def period_is_first_of_month
|
|
99
|
+
return unless period_month
|
|
100
|
+
return if period_month.day == 1
|
|
101
|
+
|
|
102
|
+
errors.add(:period_month, "must be the 1st of a month")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def draft_only_destroy
|
|
106
|
+
return if draft?
|
|
107
|
+
|
|
108
|
+
errors.add(:base, "Only draft runs can be deleted")
|
|
109
|
+
throw :abort
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One employee-month. All MONEY is snapshotted (encrypted JSON line items +
|
|
3
|
+
# encrypted totals); identity numbers (PAN/UAN/bank) render live from the
|
|
4
|
+
# profile — a typo fix must flow to old slips, and duplicating encrypted
|
|
5
|
+
# PII per month multiplies liability. Visibility and immutability delegate
|
|
6
|
+
# to the run's lifecycle.
|
|
7
|
+
class SalarySlip < ApplicationRecord
|
|
8
|
+
include EncryptedMoney
|
|
9
|
+
|
|
10
|
+
belongs_to :payroll_run
|
|
11
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
12
|
+
|
|
13
|
+
encrypts :earnings, :deductions, :employer_costs, :tax_details
|
|
14
|
+
encrypted_money :tds_override, :gross_earnings, :total_deductions, :net_pay
|
|
15
|
+
|
|
16
|
+
validates :period_month, presence: true, uniqueness: { scope: :user_id }
|
|
17
|
+
validates :user_id, uniqueness: { scope: :payroll_run_id }
|
|
18
|
+
before_save :ensure_run_editable
|
|
19
|
+
|
|
20
|
+
scope :published, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published" }) }
|
|
21
|
+
scope :recent_first, -> { order(period_month: :desc) }
|
|
22
|
+
|
|
23
|
+
def published?
|
|
24
|
+
payroll_run.published?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def earnings_rows = parse_json(earnings)
|
|
28
|
+
def deductions_rows = parse_json(deductions)
|
|
29
|
+
def employer_costs_hash = parse_json(employer_costs, fallback: {})
|
|
30
|
+
def tax_details_hash = parse_json(tax_details, fallback: {})
|
|
31
|
+
|
|
32
|
+
def effective_lop_days
|
|
33
|
+
lop_override || lop_days
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Indian FY (Apr 1) to-date sums of PUBLISHED slips before this period —
|
|
37
|
+
# feeds the TDS projector and the slip's YTD table.
|
|
38
|
+
def self.fy_to_date(user, period_month)
|
|
39
|
+
fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
|
|
40
|
+
: Date.new(period_month.year - 1, 4, 1)
|
|
41
|
+
slips = published.where(user_id: user.id)
|
|
42
|
+
.where(period_month: fy_start...period_month)
|
|
43
|
+
{
|
|
44
|
+
gross: slips.sum(BigDecimal(0)) { |s| s.gross_earnings || BigDecimal(0) },
|
|
45
|
+
tds: slips.sum(BigDecimal(0)) { |s| Money.d(s.deduction_amount("tds")) },
|
|
46
|
+
months: slips.count
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deduction_amount(code)
|
|
51
|
+
row = deductions_rows.find { |r| r["code"] == code }
|
|
52
|
+
row ? Money.d(row["amount"]) : BigDecimal(0)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pdf_cache_key
|
|
56
|
+
[ "hr_lite_slip", id, updated_at.to_i, payroll_run.status,
|
|
57
|
+
user_profile&.updated_at.to_i ].join("/")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def user_profile
|
|
61
|
+
@user_profile ||= EmployeeProfile.find_by(user_id: user_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def parse_json(raw, fallback: [])
|
|
67
|
+
raw.blank? ? fallback : JSON.parse(raw)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ensure_run_editable
|
|
71
|
+
return if payroll_run.nil? || payroll_run.editable?
|
|
72
|
+
# The lifecycle transitions themselves touch only the run row, never
|
|
73
|
+
# slip attributes — any slip write outside draft/processing/review is
|
|
74
|
+
# a bug or tampering.
|
|
75
|
+
raise ActiveRecord::ReadOnlyRecord, "slips are immutable once the run is finalized"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Versioned by effective_from (always the 1st — mid-month revisions and
|
|
3
|
+
# their blended-rate complexity are deliberately unsupported). Structures
|
|
4
|
+
# are never destroyed; a new revision supersedes. Slips snapshot every
|
|
5
|
+
# computed number, so editing a structure cannot corrupt history.
|
|
6
|
+
class SalaryStructure < ApplicationRecord
|
|
7
|
+
include EncryptedMoney
|
|
8
|
+
include Audited
|
|
9
|
+
|
|
10
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
11
|
+
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
12
|
+
|
|
13
|
+
encrypted_money :basic, :hra, :special_allowance, :other_earnings
|
|
14
|
+
|
|
15
|
+
validates :effective_from, presence: true, uniqueness: { scope: :user_id }
|
|
16
|
+
validates :basic, presence: true
|
|
17
|
+
validate :basic_positive
|
|
18
|
+
validate :effective_from_is_first_of_month
|
|
19
|
+
validates :pt_state, presence: true
|
|
20
|
+
|
|
21
|
+
def self.effective_for(user, period_month)
|
|
22
|
+
where(user_id: user.id)
|
|
23
|
+
.where(effective_from: ..period_month.beginning_of_month)
|
|
24
|
+
.order(effective_from: :desc)
|
|
25
|
+
.first
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def monthly_gross
|
|
29
|
+
[ basic, hra, special_allowance, other_earnings ].compact.sum(BigDecimal(0))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def annual_gross
|
|
33
|
+
monthly_gross * 12
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def basic_positive
|
|
39
|
+
errors.add(:basic, "must be greater than zero") if basic && basic <= 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def effective_from_is_first_of_month
|
|
43
|
+
return unless effective_from
|
|
44
|
+
return if effective_from.day == 1
|
|
45
|
+
|
|
46
|
+
errors.add(:effective_from, "must be the 1st of a month")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Engine-owned singleton settings row.
|
|
3
|
+
class Setting < ApplicationRecord
|
|
4
|
+
include Audited
|
|
5
|
+
|
|
6
|
+
WEEKEND_POLICIES = %w[sun_only sat_sun second_fourth_sat_sun].freeze
|
|
7
|
+
|
|
8
|
+
validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
|
|
9
|
+
|
|
10
|
+
def self.instance
|
|
11
|
+
first_or_create!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The single write path for employee punches. Race-safe (DB unique index +
|
|
3
|
+
# row lock + in-lock re-check), never blocked by geolocation: a punch
|
|
4
|
+
# without GPS or outside every office radius is recorded AND flagged —
|
|
5
|
+
# denying GPS must not stop anyone from working.
|
|
6
|
+
class AttendancePuncher
|
|
7
|
+
Result = Struct.new(:record, :error, keyword_init: true) do
|
|
8
|
+
def ok? = error.nil?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok")
|
|
12
|
+
new(user, kind.to_sym, lat, lng, accuracy_m, geo_status.to_s).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(user, kind, lat, lng, accuracy_m, geo_status)
|
|
16
|
+
@user = user
|
|
17
|
+
@kind = kind
|
|
18
|
+
@lat = lat.presence
|
|
19
|
+
@lng = lng.presence
|
|
20
|
+
@accuracy_m = accuracy_m.presence
|
|
21
|
+
@geo_status = geo_status
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
record = resolve_record
|
|
26
|
+
return Result.new(error: "No open check-in to close.") if record.nil?
|
|
27
|
+
|
|
28
|
+
record.with_lock do
|
|
29
|
+
case @kind
|
|
30
|
+
when :check_in
|
|
31
|
+
return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
|
|
32
|
+
|
|
33
|
+
stamp(record, :check_in)
|
|
34
|
+
when :check_out
|
|
35
|
+
return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
|
|
36
|
+
|
|
37
|
+
stamp(record, :check_out) # repeat check-out overwrites: last out wins
|
|
38
|
+
else
|
|
39
|
+
return Result.new(error: "Unknown punch #{@kind}.")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
apply_geo_flags(record)
|
|
43
|
+
record.save!
|
|
44
|
+
end
|
|
45
|
+
notify_if_flagged(record)
|
|
46
|
+
Result.new(record: record)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def resolve_record
|
|
52
|
+
if @kind == :check_out
|
|
53
|
+
today = AttendanceRecord.find_by(user_id: @user.id, date: Date.current)
|
|
54
|
+
return today if today&.check_in_at.present?
|
|
55
|
+
|
|
56
|
+
# Post-midnight close of yesterday's open punch (office night, IST).
|
|
57
|
+
return AttendanceRecord.for_date(Date.current - 1)
|
|
58
|
+
.missing_checkout.find_by(user_id: @user.id)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
AttendanceRecord.find_or_create_by!(user_id: @user.id, date: Date.current)
|
|
62
|
+
rescue ActiveRecord::RecordNotUnique
|
|
63
|
+
retry
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stamp(record, side)
|
|
67
|
+
record.public_send("#{side}_at=", Time.current)
|
|
68
|
+
record.public_send("#{side}_lat=", @lat)
|
|
69
|
+
record.public_send("#{side}_lng=", @lng)
|
|
70
|
+
record.public_send("#{side}_accuracy_m=", @accuracy_m)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def already_in_message(record)
|
|
74
|
+
"Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def apply_geo_flags(record)
|
|
78
|
+
label = @kind == :check_in ? "Check-in" : "Check-out"
|
|
79
|
+
|
|
80
|
+
if @lat.nil? || @lng.nil?
|
|
81
|
+
reason = @geo_status == "ok" ? "unavailable" : @geo_status
|
|
82
|
+
record.add_flag!("#{label} without GPS (#{reason})")
|
|
83
|
+
elsif OfficeLocation.active.exists? && !OfficeLocation.covering?(@lat, @lng)
|
|
84
|
+
office = OfficeLocation.nearest(@lat, @lng)
|
|
85
|
+
distance_km = (Geo.distance_m(office.lat, office.lng, @lat, @lng) / 1000.0).round(1)
|
|
86
|
+
accuracy = @accuracy_m ? " (±#{@accuracy_m} m)" : ""
|
|
87
|
+
record.add_flag!("#{label} #{distance_km} km from #{office.name}#{accuracy}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def notify_if_flagged(record)
|
|
92
|
+
return unless record.saved_changes.key?("flag_note") && record.flagged?
|
|
93
|
+
|
|
94
|
+
Notifications.publish(
|
|
95
|
+
"attendance.flagged",
|
|
96
|
+
title: "Flagged punch — #{HrLite.display_name(@user)}",
|
|
97
|
+
body: record.flag_note,
|
|
98
|
+
path: "/admin/attendances?date=#{record.date}",
|
|
99
|
+
bell_to: HrLite.admin_users
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|