hr_lite 0.8.0 → 0.11.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 +4 -4
- data/CHANGELOG.md +149 -1
- data/app/controllers/hr_lite/approvals_controller.rb +31 -0
- data/app/helpers/hr_lite/application_helper.rb +21 -0
- data/app/jobs/hr_lite/approval_escalation_job.rb +39 -0
- data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
- data/app/models/concerns/hr_lite/approvable.rb +48 -0
- data/app/models/hr_lite/approval.rb +46 -0
- data/app/models/hr_lite/approval_delegation.rb +38 -0
- data/app/models/hr_lite/approval_flow.rb +39 -0
- data/app/models/hr_lite/approval_step.rb +62 -0
- data/app/models/hr_lite/document.rb +95 -0
- data/app/models/hr_lite/leave_request.rb +72 -17
- data/app/models/hr_lite/loan.rb +65 -0
- data/app/models/hr_lite/loan_repayment.rb +14 -0
- data/app/models/hr_lite/payroll_line_item.rb +57 -0
- data/app/models/hr_lite/payroll_run.rb +34 -0
- data/app/models/hr_lite/salary_component.rb +59 -0
- data/app/models/hr_lite/tax_declaration.rb +93 -0
- data/app/models/hr_lite/tax_declaration_item.rb +32 -0
- data/app/services/hr_lite/approval_route.rb +109 -0
- data/app/services/hr_lite/slip_builder.rb +76 -3
- data/app/views/hr_lite/approvals/index.html.erb +66 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260818001151_create_hr_lite_approvals.rb +79 -0
- data/db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb +72 -0
- data/db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb +73 -0
- data/lib/hr_lite/notifications.rb +10 -0
- data/lib/hr_lite/permissions.rb +4 -0
- data/lib/hr_lite/role_seeds.rb +6 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/tasks/hr_lite_tasks.rake +2 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 655b4d27557dace6262528eb82af6912b4b7dfcdc3ff6b8274068876b8bb970c
|
|
4
|
+
data.tar.gz: ba15eee422c61704681db9d8d1194851f8c761459524844ade6e4cd054776f81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b4224f36f2cd4b3c256d440e6f439f59ce86e61436545f4f5a1afab97d827c64db86b4ee3b21f72003ea8864010b0369d6a719cd833a7194855adc1af10602c
|
|
7
|
+
data.tar.gz: 3de774d902610ab761c5794fe4f9eec3c404dac26ccf0fbea553bfe6bafe09e45c576e8264b07c70b6eef07af3f93f54c88aa43d3fd149921d4726456823a890
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,151 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
Documents, and a tax declaration that is more than one number. **Adds one
|
|
13
|
+
migration.** Requires Active Storage on the host (`rails active_storage:install`).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`hr_lite_documents`** — there was nowhere to keep an employee's documents
|
|
18
|
+
at all. Files attach through Active Storage, which content-sniffs via
|
|
19
|
+
Marcel, so the type allowlist checks real bytes rather than what the client
|
|
20
|
+
claimed. SVG and HTML stay out: both carry script and both render in a
|
|
21
|
+
browser. 10 MB cap.
|
|
22
|
+
- **Visibility is a property of the row**, and an identity document defaults
|
|
23
|
+
to the tightest setting rather than the convenient one — an Aadhaar, PAN,
|
|
24
|
+
passport or bank mandate is money-tier by default, not HR's to browse. A
|
|
25
|
+
default nobody thinks about is the one that leaks. An explicit choice always
|
|
26
|
+
wins, which is why the column carries no default of its own.
|
|
27
|
+
- Verification with a named checker, and expiry with a job that warns at 30
|
|
28
|
+
days and again at 7 — not daily, because a warning that arrives every day
|
|
29
|
+
is one nobody reads by the third.
|
|
30
|
+
- **`hr_lite_tax_declarations`** and items, replacing
|
|
31
|
+
`declared_annual_deductions`: ONE opaque figure an admin typed in, that
|
|
32
|
+
nobody could see the makeup of, that the employee could not submit
|
|
33
|
+
themselves, and that had nowhere to keep the proof. It is the number the
|
|
34
|
+
whole year's TDS is projected from.
|
|
35
|
+
- Sections (80C, 80D, 80CCD(1B), 24(b), HRA), each with what was **declared**
|
|
36
|
+
and what the proof actually **supported**. Until HR has looked, the declared
|
|
37
|
+
figure stands — asking somebody to overpay tax all year because paperwork is
|
|
38
|
+
slow is its own kind of wrong. Once verified, only the supported figure
|
|
39
|
+
counts.
|
|
40
|
+
- The regime follows the declaration, because choosing one is a per-YEAR
|
|
41
|
+
decision and the profile column cannot express that.
|
|
42
|
+
- Permissions `document.view`, `document.manage`, `tax.view`, `tax.manage`,
|
|
43
|
+
granted to the roles that should hold them.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `SlipBuilder` reads the declaration when one exists for the run's financial
|
|
48
|
+
year, falling back to the profile figure otherwise. A DRAFT declaration is
|
|
49
|
+
ignored — a draft is not a claim.
|
|
50
|
+
|
|
51
|
+
## [0.10.0] - 2026-08-18
|
|
52
|
+
|
|
53
|
+
Payroll gets heads, arrears and loans. **Adds one migration.** Existing
|
|
54
|
+
payslips compute identically — every new head is opt-in data.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **`hr_lite_salary_components`** — earning and deduction heads as DATA.
|
|
59
|
+
Payroll knew exactly four earnings because they were columns on the salary
|
|
60
|
+
structure, so a bonus, an incentive, an LTA or a reimbursement had nowhere
|
|
61
|
+
to go but "other": one number on a payslip that should have said what it
|
|
62
|
+
was for. Seeded with bonus, incentive, arrears, LTA, reimbursement, loan
|
|
63
|
+
repayment and other-deduction; an install adds its own.
|
|
64
|
+
- Each head declares whether it is **prorated** (a bonus is not halved
|
|
65
|
+
because somebody joined on the 16th), **taxable**, and whether it
|
|
66
|
+
**counts toward the ESI gross** — a reimbursement is not a wage, and
|
|
67
|
+
counting it could push somebody over the ceiling and out of cover.
|
|
68
|
+
- **`hr_lite_payroll_line_items`** — one-offs for a single month, including
|
|
69
|
+
arrears after a backdated revision. Dated to the MONTH rather than the run,
|
|
70
|
+
so deleting a draft and computing it again does not lose them. Refused on a
|
|
71
|
+
month already published: that slip is immutable, and a later line would
|
|
72
|
+
show on no payslip while quietly moving the year-to-date the TDS projector
|
|
73
|
+
reads.
|
|
74
|
+
- **`hr_lite_loans`** and repayments. The outstanding balance is DERIVED from
|
|
75
|
+
the repayments actually taken, never stored — a stored balance and a
|
|
76
|
+
recomputed run disagree the first time somebody deletes a draft. The last
|
|
77
|
+
instalment takes only what is left.
|
|
78
|
+
- Repayments are booked when a run is **finalized**, not at compute: a draft
|
|
79
|
+
is recomputed as often as the operator likes, and each pass would otherwise
|
|
80
|
+
take another instalment. Unlocking a run gives them back, and reopens a
|
|
81
|
+
loan that had closed.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- Branch coverage is now a **floor at 90%, not a ratchet**, with the reasoning
|
|
86
|
+
written into `spec_helper.rb`. Its denominator grows with every subsystem,
|
|
87
|
+
so ratcheting it charges each feature PR a tax for branches in files it
|
|
88
|
+
never touched. Line coverage remains the ratchet at 100% overall and 90%
|
|
89
|
+
per file — that is the gate that has actually caught defects.
|
|
90
|
+
- Paid down some of the branch debt named in 0.9.0: the status predicates
|
|
91
|
+
across six models, the UAN format validation, and the holiday bulk-import
|
|
92
|
+
paths (blank lines, an empty paste, a line that cannot be read).
|
|
93
|
+
|
|
94
|
+
## [0.9.0] - 2026-08-18
|
|
95
|
+
|
|
96
|
+
A reusable approval engine. **Adds one migration** (flows, steps, approvals,
|
|
97
|
+
delegations). Nothing changes for a module until a flow is configured for it,
|
|
98
|
+
which is what lets the four existing ones migrate one at a time.
|
|
99
|
+
|
|
100
|
+
### Fixed
|
|
101
|
+
|
|
102
|
+
- **Cancelling a leave request left its approval sitting in somebody's
|
|
103
|
+
inbox.** Found by the coverage floor, which flagged `cancel_all!` as
|
|
104
|
+
unreachable — it was written and never called.
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- `HrLite::ApprovalFlow` / `ApprovalStep` / `Approval` / `ApprovalDelegation`.
|
|
109
|
+
A flow is "leave needs the manager, then HR". A step names its approver by
|
|
110
|
+
RULE — manager, manager's manager, anyone holding a permission, or one named
|
|
111
|
+
person — so a flow survives somebody leaving; the rule resolves against the
|
|
112
|
+
subject when the request is raised.
|
|
113
|
+
- Multi-level, sequential routing, with a `unanimous` step for the rungs where
|
|
114
|
+
everybody has to answer rather than the first person to look.
|
|
115
|
+
- **A rung nobody occupies is skipped**, not left waiting. An employee with no
|
|
116
|
+
manager recorded would otherwise have a request no living person could
|
|
117
|
+
decide.
|
|
118
|
+
- **Delegation.** "I am away until the 14th — Priya decides for me." The
|
|
119
|
+
approval does not move; a stand-in may answer it, and the row records both
|
|
120
|
+
who it was addressed to and who actually decided. Two hops, so a stand-in
|
|
121
|
+
who is also away is covered, and a loop cannot hand somebody their own
|
|
122
|
+
approvals back.
|
|
123
|
+
- **SLA and escalation.** A step may carry a deadline; `ApprovalEscalationJob`
|
|
124
|
+
tells an approver once per overdue row, grouped into one message per person,
|
|
125
|
+
and stamps the rows so a daily run does not nag daily. Escalation TELLS
|
|
126
|
+
somebody — it does not reassign, because a decision made by somebody who
|
|
127
|
+
never saw the request is worse than a late one.
|
|
128
|
+
- **One approval inbox** (`/approvals`) replacing four places to look. Employee
|
|
129
|
+
tier: holding an approval is the authorisation, so a manager, a stand-in and
|
|
130
|
+
a director reach the same screen and each sees only their own rows. It sits
|
|
131
|
+
after Calendar in the nav — the phone tab bar shows the first four, and this
|
|
132
|
+
screen is empty for anybody who is not an approver.
|
|
133
|
+
- `HrLite::Approvable` — opts a model in WITHOUT taking over what its decisions
|
|
134
|
+
mean. `LeaveRequest` keeps its own `approve!`, including the balance lock;
|
|
135
|
+
the concern only answers "is it this person's turn, and does their answer
|
|
136
|
+
settle it". A settling decision runs the ordinary transition, so routing
|
|
137
|
+
never becomes a second path that can drift from the first.
|
|
138
|
+
|
|
139
|
+
### Changed
|
|
140
|
+
|
|
141
|
+
- `LeaveRequest` is the first module routed. With no flow it behaves exactly
|
|
142
|
+
as before, and somebody outside the current rung who holds `leave.approve`
|
|
143
|
+
at `all` can still settle it outright — the routed path is the normal one,
|
|
144
|
+
not the only one.
|
|
145
|
+
- Branch-coverage floor 90.8% -> 90.3%, deliberately, with the arithmetic
|
|
146
|
+
written into `spec_helper.rb`: the engine added ~90 branches at once, and
|
|
147
|
+
holding the percentage would have meant contriving specs for untouched
|
|
148
|
+
files. Line coverage stayed at 100%.
|
|
149
|
+
|
|
150
|
+
### Not yet
|
|
151
|
+
|
|
152
|
+
- Comp-off, regularization and resignation still use their own single
|
|
153
|
+
decision. They are declared approvable and migrate next, one release each.
|
|
154
|
+
|
|
10
155
|
## [0.8.0] - 2026-08-18
|
|
11
156
|
|
|
12
157
|
The deprecation 0.6.0 opened, closed. **Breaking** only for an install still
|
|
@@ -549,7 +694,10 @@ Initial release.
|
|
|
549
694
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
550
695
|
daily leadership digest and an append-only audit trail.
|
|
551
696
|
|
|
552
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
697
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...HEAD
|
|
698
|
+
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
699
|
+
[0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
|
|
700
|
+
[0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
|
|
553
701
|
[0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
|
|
554
702
|
[0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
|
|
555
703
|
[0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Everything waiting on the signed-in person, in one place. Before this,
|
|
3
|
+
# an approver had to visit four screens to find out whether anybody was
|
|
4
|
+
# waiting on them, and nothing told them if a request had gone stale.
|
|
5
|
+
#
|
|
6
|
+
# Employee tier on purpose: holding an approval IS the authorisation. A
|
|
7
|
+
# manager, a stand-in covering for one, and a director all reach the same
|
|
8
|
+
# screen and each sees only their own rows.
|
|
9
|
+
class ApprovalsController < ApplicationController
|
|
10
|
+
def index
|
|
11
|
+
@approvals = paginate(mine.includes(:step, :subject).order(:created_at))
|
|
12
|
+
@delegations = ApprovalDelegation.live_on(Date.current)
|
|
13
|
+
.where(from_user_id: hr_current_user.id)
|
|
14
|
+
.includes(:to_user)
|
|
15
|
+
@covering_for = ApprovalDelegation.live_on(Date.current)
|
|
16
|
+
.where(to_user_id: hr_current_user.id)
|
|
17
|
+
.includes(:from_user)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Rows addressed to this person, plus rows belonging to anybody who has
|
|
23
|
+
# delegated to them while they are away.
|
|
24
|
+
def mine
|
|
25
|
+
standing_in_for = ApprovalDelegation.live_on(Date.current)
|
|
26
|
+
.where(to_user_id: hr_current_user.id)
|
|
27
|
+
.pluck(:from_user_id)
|
|
28
|
+
Approval.pending.where(approver_id: [ hr_current_user.id, *standing_in_for ])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -6,6 +6,12 @@ module HrLite
|
|
|
6
6
|
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
|
|
7
7
|
{ label: "Team", path: :team_path, match: [ "/team" ] },
|
|
8
8
|
{ label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
|
|
9
|
+
# After Calendar deliberately: the phone tab bar shows the first four
|
|
10
|
+
# and this screen is empty for everybody who is not an approver, so it
|
|
11
|
+
# belongs in "More" rather than displacing something everybody uses.
|
|
12
|
+
# Employee tier all the same — holding an approval IS the authorisation,
|
|
13
|
+
# so a manager and a stand-in covering for one reach the same screen.
|
|
14
|
+
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
|
|
9
15
|
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
10
16
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
11
17
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
@@ -71,6 +77,21 @@ module HrLite
|
|
|
71
77
|
out.html_safe
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
# Where an approval row actually leads. Each subject type has its own
|
|
81
|
+
# decision screen; the inbox only gathers them.
|
|
82
|
+
APPROVAL_PATHS = {
|
|
83
|
+
"HrLite::LeaveRequest" => :admin_leave_request_path,
|
|
84
|
+
"HrLite::CompOffRequest" => :admin_comp_off_request_path,
|
|
85
|
+
"HrLite::RegularizationRequest" => :admin_regularization_request_path
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
def hrl_approval_path(approval)
|
|
89
|
+
helper = APPROVAL_PATHS[approval.subject_type]
|
|
90
|
+
return nil unless helper && hr_lite_route?(helper)
|
|
91
|
+
|
|
92
|
+
hr_lite.public_send(helper, approval.subject_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
74
95
|
def hrl_pagination
|
|
75
96
|
render "hr_lite/shared/pagination"
|
|
76
97
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Nudges approvals that have sat past their step's SLA. Escalation here
|
|
3
|
+
# means TELLING SOMEBODY, not reassigning: silently moving a decision to
|
|
4
|
+
# another person is how an approval ends up made by somebody who never saw
|
|
5
|
+
# the request.
|
|
6
|
+
#
|
|
7
|
+
# Each row is stamped once, so a daily run does not re-nag every day.
|
|
8
|
+
class ApprovalEscalationJob < ApplicationJob
|
|
9
|
+
def perform(now: Time.current)
|
|
10
|
+
overdue = Approval.pending.where(escalated_at: nil).includes(:step, :approver)
|
|
11
|
+
.select { |approval| approval.overdue?(now) }
|
|
12
|
+
return if overdue.empty?
|
|
13
|
+
|
|
14
|
+
overdue.group_by(&:approver_id).each do |approver_id, rows|
|
|
15
|
+
approver = rows.first.approver
|
|
16
|
+
next if approver.nil?
|
|
17
|
+
|
|
18
|
+
Notifications.publish(
|
|
19
|
+
"approval.escalated",
|
|
20
|
+
title: "#{rows.size} approval#{'s' unless rows.size == 1} waiting on you past its deadline",
|
|
21
|
+
lines: rows.map { |row| "#{row.label} — waiting #{waited(row, now)}" },
|
|
22
|
+
path: "/approvals",
|
|
23
|
+
bell_to: [ approver ],
|
|
24
|
+
email_to: [ approver ],
|
|
25
|
+
leadership: { title: "#{HrLite.display_name(approver)} has #{rows.size} overdue approval#{'s' unless rows.size == 1}" }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Approval.where(id: overdue.map(&:id)).update_all(escalated_at: now) # rubocop:disable Rails/SkipsModelValidations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def waited(approval, now)
|
|
35
|
+
hours = ((now - approval.created_at) / 1.hour).floor
|
|
36
|
+
hours < 48 ? "#{hours}h" : "#{(hours / 24)} days"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Tells people before a document lapses. A passport or a visa is somebody's
|
|
3
|
+
# right to work, and the whole problem with an expiry date is finding out
|
|
4
|
+
# about it late.
|
|
5
|
+
#
|
|
6
|
+
# Warns at 30 days and again at 7, rather than every day for a month.
|
|
7
|
+
class DocumentExpiryJob < ApplicationJob
|
|
8
|
+
WINDOWS = [ 30, 7 ].freeze
|
|
9
|
+
|
|
10
|
+
def perform(today: Date.current)
|
|
11
|
+
WINDOWS.each do |days|
|
|
12
|
+
Document.where(expires_on: today + days).includes(:user).find_each do |document|
|
|
13
|
+
Notifications.publish(
|
|
14
|
+
"document.expiring",
|
|
15
|
+
title: "#{document.title} expires in #{days} days",
|
|
16
|
+
body: "#{HrLite.display_name(document.user)} — #{document.category.humanize}, " \
|
|
17
|
+
"expiring #{document.expires_on.strftime('%d %b %Y')}.",
|
|
18
|
+
path: "/profile",
|
|
19
|
+
bell_to: [ document.user ],
|
|
20
|
+
email_to: [ document.user ],
|
|
21
|
+
leadership: { title: "#{HrLite.display_name(document.user)}'s #{document.title} " \
|
|
22
|
+
"expires in #{days} days" }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Opts a model into the routed, multi-step approval flow — WITHOUT taking
|
|
3
|
+
# over what its decisions mean.
|
|
4
|
+
#
|
|
5
|
+
# The model keeps its own `approve!` / `reject!`: those are the domain
|
|
6
|
+
# actions, and for leave one of them credits a balance inside a row lock.
|
|
7
|
+
# This concern only asks "is it this person's turn, and does their answer
|
|
8
|
+
# settle it?" and hands back an outcome for the model to act on.
|
|
9
|
+
#
|
|
10
|
+
# A subject type with no active flow behaves exactly as it did before —
|
|
11
|
+
# single decision, whoever holds the permission. That is what lets the four
|
|
12
|
+
# existing modules migrate one at a time instead of all at once.
|
|
13
|
+
module Approvable
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
has_many :approvals, as: :subject, class_name: "HrLite::Approval", dependent: :destroy
|
|
18
|
+
after_create_commit :open_approval_route
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def approval_route = @approval_route ||= ApprovalRoute.new(self)
|
|
22
|
+
|
|
23
|
+
def routed_for_approval? = approval_route.routed?
|
|
24
|
+
|
|
25
|
+
# The rows still waiting, newest rung first — what the show screen lists.
|
|
26
|
+
def pending_approvals = approvals.pending.order(:position)
|
|
27
|
+
|
|
28
|
+
def approval_for(user)
|
|
29
|
+
return nil if user.nil?
|
|
30
|
+
|
|
31
|
+
pending_approvals.detect { |approval| approval.answerable_by?(user) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether this person is the one being waited on. Unrouted subjects fall
|
|
35
|
+
# back to the permission check the module already used.
|
|
36
|
+
def awaiting?(user) = approval_for(user).present?
|
|
37
|
+
|
|
38
|
+
# The whole story, for the approval screen: who decided what, in order,
|
|
39
|
+
# including the rungs that were skipped and why.
|
|
40
|
+
def approval_history = approvals.order(:position, :id)
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def open_approval_route
|
|
45
|
+
approval_route.open! if approval_route.routed?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One person's outstanding decision on one record. The row is the unit the
|
|
3
|
+
# inbox lists and the escalation job reads.
|
|
4
|
+
class Approval < ApplicationRecord
|
|
5
|
+
STATUSES = %w[pending approved rejected returned skipped cancelled].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :step, class_name: "HrLite::ApprovalStep"
|
|
8
|
+
belongs_to :subject, polymorphic: true
|
|
9
|
+
belongs_to :approver, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
|
|
14
|
+
scope :pending, -> { where(status: "pending") }
|
|
15
|
+
scope :at_position, ->(position) { where(position: position) }
|
|
16
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
17
|
+
|
|
18
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
19
|
+
|
|
20
|
+
# Whether `user` may answer this — the approver themselves, or somebody
|
|
21
|
+
# they have delegated to while they are away.
|
|
22
|
+
def answerable_by?(user)
|
|
23
|
+
return false if user.nil? || !pending?
|
|
24
|
+
|
|
25
|
+
approver_id == user.id ||
|
|
26
|
+
ApprovalDelegation.stand_ins_for(approver_id).include?(user.id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def decide!(status:, actor:, note: nil)
|
|
30
|
+
raise ArgumentError, "unknown decision #{status}" unless STATUSES.include?(status.to_s)
|
|
31
|
+
|
|
32
|
+
update!(status: status.to_s, note: note.presence, decided_at: Time.current,
|
|
33
|
+
decided_by_id: actor&.id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True once this row's SLA has run out. Computed rather than stored so
|
|
37
|
+
# editing a step's SLA takes effect on the rows already waiting.
|
|
38
|
+
def overdue?(now = Time.current)
|
|
39
|
+
return false unless pending? && step.sla_hours
|
|
40
|
+
|
|
41
|
+
created_at + step.sla_hours.hours <= now
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def label = "#{subject_type.demodulize.underscore.humanize} ##{subject_id}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I am away until the 14th — Priya decides for me." Delegation does not
|
|
3
|
+
# move the approval; it lets somebody else answer it, and the row records
|
|
4
|
+
# who actually did.
|
|
5
|
+
class ApprovalDelegation < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :from_user, class_name: HrLite.config.user_class
|
|
9
|
+
belongs_to :to_user, class_name: HrLite.config.user_class
|
|
10
|
+
|
|
11
|
+
validates :starts_on, :ends_on, presence: true
|
|
12
|
+
validate :ends_after_it_starts
|
|
13
|
+
validate :not_to_themselves
|
|
14
|
+
|
|
15
|
+
scope :live_on, ->(date) { where(starts_on: ..date).where(ends_on: date..) }
|
|
16
|
+
|
|
17
|
+
# Who may answer on `user`'s behalf today, including a chain of two —
|
|
18
|
+
# A delegates to B, B is also away and delegates to C. Capped at two
|
|
19
|
+
# hops: past that it is quicker to reassign the flow than to follow it.
|
|
20
|
+
def self.stand_ins_for(user_id, on: Date.current)
|
|
21
|
+
first = live_on(on).where(from_user_id: user_id).pluck(:to_user_id)
|
|
22
|
+
second = live_on(on).where(from_user_id: first).pluck(:to_user_id)
|
|
23
|
+
(first + second - [ user_id ]).uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def ends_after_it_starts
|
|
29
|
+
return unless starts_on && ends_on
|
|
30
|
+
|
|
31
|
+
errors.add(:ends_on, "must be on or after the start date") if ends_on < starts_on
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def not_to_themselves
|
|
35
|
+
errors.add(:to_user_id, "cannot be the person delegating") if from_user_id == to_user_id
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Leave needs the manager, then HR." One active flow per subject type;
|
|
3
|
+
# a type with no flow keeps whatever single-decision behaviour it had.
|
|
4
|
+
class ApprovalFlow < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
has_many :approval_steps, -> { order(:position) },
|
|
8
|
+
class_name: "HrLite::ApprovalStep", foreign_key: :flow_id, dependent: :destroy
|
|
9
|
+
accepts_nested_attributes_for :approval_steps, allow_destroy: true
|
|
10
|
+
|
|
11
|
+
validates :subject_type, :name, presence: true
|
|
12
|
+
validate :subject_type_is_approvable
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(active: true) }
|
|
15
|
+
|
|
16
|
+
def self.for(subject_type)
|
|
17
|
+
active.find_by(subject_type: subject_type.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Subject types that have opted in. Declared rather than derived so a
|
|
21
|
+
# flow cannot be pointed at a model that has no idea it is approvable.
|
|
22
|
+
def self.approvable_types
|
|
23
|
+
%w[
|
|
24
|
+
HrLite::LeaveRequest HrLite::CompOffRequest
|
|
25
|
+
HrLite::RegularizationRequest HrLite::Resignation
|
|
26
|
+
].freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subject_label = subject_type.demodulize.underscore.humanize
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def subject_type_is_approvable
|
|
34
|
+
return if self.class.approvable_types.include?(subject_type)
|
|
35
|
+
|
|
36
|
+
errors.add(:subject_type, "is not a type this engine can route for approval")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One rung. The approver is named by RULE, not by person, so a flow
|
|
3
|
+
# survives somebody leaving the company — the rule is resolved against the
|
|
4
|
+
# subject at the moment the request is raised.
|
|
5
|
+
class ApprovalStep < ApplicationRecord
|
|
6
|
+
RULES = %w[manager manager_of_manager permission user].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :flow, class_name: "HrLite::ApprovalFlow"
|
|
9
|
+
|
|
10
|
+
validates :position, presence: true, uniqueness: { scope: :flow_id }
|
|
11
|
+
validates :approver_rule, inclusion: { in: RULES }
|
|
12
|
+
validates :sla_hours, numericality: { greater_than: 0 }, allow_nil: true
|
|
13
|
+
validate :approver_key_matches_the_rule
|
|
14
|
+
|
|
15
|
+
scope :ordered, -> { order(:position) }
|
|
16
|
+
|
|
17
|
+
# Everybody who must (or may) decide this rung for `subject`. Empty means
|
|
18
|
+
# nobody fits — `ApprovalRoute` treats that as a rung to skip rather than
|
|
19
|
+
# a request nobody can ever answer.
|
|
20
|
+
# Array(...) rather than an `else` arm: `approver_rule` is held to the
|
|
21
|
+
# four RULES by a validation AND a database check constraint, so an
|
|
22
|
+
# unmatched `case` cannot happen — and a defensive branch nothing can
|
|
23
|
+
# reach is a line that never gets tested and quietly rots.
|
|
24
|
+
def approvers_for(subject)
|
|
25
|
+
Array(
|
|
26
|
+
case approver_rule
|
|
27
|
+
when "manager" then manager_of(subject.user_id)
|
|
28
|
+
when "manager_of_manager" then manager_of(manager_of(subject.user_id)&.id)
|
|
29
|
+
when "permission" then HrLite.users_holding(approver_key, scope: :all).to_a
|
|
30
|
+
when "user" then HrLite.user_klass.find_by(id: approver_key)
|
|
31
|
+
end
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def label
|
|
36
|
+
case approver_rule
|
|
37
|
+
when "manager" then "Their manager"
|
|
38
|
+
when "manager_of_manager" then "Their manager's manager"
|
|
39
|
+
when "permission" then "Anyone who can #{Permissions.description(approver_key).downcase}"
|
|
40
|
+
when "user" then HrLite.display_name(HrLite.user_klass.find_by(id: approver_key))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def manager_of(user_id)
|
|
47
|
+
return nil if user_id.nil?
|
|
48
|
+
|
|
49
|
+
manager_id = EmployeeProfile.where(user_id: user_id).pick(:manager_id)
|
|
50
|
+
manager_id && HrLite.user_klass.find_by(id: manager_id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def approver_key_matches_the_rule
|
|
54
|
+
case approver_rule
|
|
55
|
+
when "permission"
|
|
56
|
+
errors.add(:approver_key, "must be a known permission") unless Permissions.valid?(approver_key.to_s)
|
|
57
|
+
when "user"
|
|
58
|
+
errors.add(:approver_key, "must name a user") if approver_key.blank?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An employee's document: an Aadhaar scan, a PAN, an offer letter, a
|
|
3
|
+
# certificate. The FILE is the sensitive part, so visibility is a property
|
|
4
|
+
# of the row and every read goes through `readable_by?`.
|
|
5
|
+
class Document < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
VISIBILITIES = %w[self hr money].freeze
|
|
9
|
+
VERIFICATIONS = %w[pending verified rejected].freeze
|
|
10
|
+
|
|
11
|
+
# Categories the engine knows how to reason about. An install may store
|
|
12
|
+
# any string; these are the ones with an opinion attached.
|
|
13
|
+
SENSITIVE_CATEGORIES = %w[aadhaar pan passport bank].freeze
|
|
14
|
+
|
|
15
|
+
# Active Storage content-sniffs via Marcel, so this checks the real bytes
|
|
16
|
+
# rather than whatever the client claimed. SVG and HTML stay out — both
|
|
17
|
+
# can carry script and both render in a browser.
|
|
18
|
+
ALLOWED_TYPES = %w[
|
|
19
|
+
application/pdf image/jpeg image/png image/heic image/webp
|
|
20
|
+
].freeze
|
|
21
|
+
MAX_BYTES = 10.megabytes
|
|
22
|
+
|
|
23
|
+
has_one_attached :file
|
|
24
|
+
|
|
25
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
26
|
+
belongs_to :verified_by, class_name: HrLite.config.user_class, optional: true
|
|
27
|
+
belongs_to :uploaded_by, class_name: HrLite.config.user_class, optional: true
|
|
28
|
+
|
|
29
|
+
validates :category, :title, presence: true
|
|
30
|
+
validates :visibility, inclusion: { in: VISIBILITIES }
|
|
31
|
+
validates :verification, inclusion: { in: VERIFICATIONS }
|
|
32
|
+
validate :file_is_an_allowed_kind
|
|
33
|
+
validate :file_is_not_too_large
|
|
34
|
+
before_validation :default_visibility_from_category, on: :create
|
|
35
|
+
|
|
36
|
+
scope :for_user, ->(user) { where(user_id: user.id) }
|
|
37
|
+
scope :expiring_before, ->(date) { where.not(expires_on: nil).where(expires_on: ..date) }
|
|
38
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
39
|
+
|
|
40
|
+
VERIFICATIONS.each { |v| define_method("#{v}?") { verification == v } }
|
|
41
|
+
|
|
42
|
+
def expired?(on = Date.current) = expires_on.present? && expires_on < on
|
|
43
|
+
|
|
44
|
+
# Who may open the FILE. The owner always may; beyond that it is the
|
|
45
|
+
# permission the row's visibility names. A passport is not a payslip and
|
|
46
|
+
# neither is HR's to browse by default.
|
|
47
|
+
def readable_by?(reader)
|
|
48
|
+
return false if reader.nil?
|
|
49
|
+
return true if reader.id == user_id
|
|
50
|
+
|
|
51
|
+
case visibility
|
|
52
|
+
when "self" then false
|
|
53
|
+
when "hr" then HrLite.reaches?(reader, "document.view", user)
|
|
54
|
+
when "money" then HrLite.can?(reader, "document.manage", scope: :all)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def verify!(actor:, note: nil)
|
|
59
|
+
update!(verification: "verified", verified_by_id: actor.id,
|
|
60
|
+
verified_at: Time.current, verification_note: note.presence)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reject!(actor:, note:)
|
|
64
|
+
raise ArgumentError, "a rejection needs a reason" if note.blank?
|
|
65
|
+
|
|
66
|
+
update!(verification: "rejected", verified_by_id: actor.id,
|
|
67
|
+
verified_at: Time.current, verification_note: note)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# An Aadhaar or a bank mandate defaults to the tightest setting rather
|
|
73
|
+
# than the convenient one — a default nobody thinks about is the one that
|
|
74
|
+
# leaks. An explicit choice always wins, which is why the column carries
|
|
75
|
+
# no default of its own.
|
|
76
|
+
def default_visibility_from_category
|
|
77
|
+
return if visibility.present?
|
|
78
|
+
|
|
79
|
+
self.visibility = SENSITIVE_CATEGORIES.include?(category.to_s) ? "money" : "hr"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def file_is_an_allowed_kind
|
|
83
|
+
return unless file.attached?
|
|
84
|
+
return if ALLOWED_TYPES.include?(file.blob.content_type)
|
|
85
|
+
|
|
86
|
+
errors.add(:file, "must be a PDF or an image — #{file.blob.content_type} is not accepted")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def file_is_not_too_large
|
|
90
|
+
return unless file.attached? && file.blob.byte_size > MAX_BYTES
|
|
91
|
+
|
|
92
|
+
errors.add(:file, "must be under #{MAX_BYTES / 1.megabyte} MB")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|