@kernhq/module-hr 0.6.0 → 0.8.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.
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +31 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/permissions.d.ts +23 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +27 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/policies.d.ts +256 -0
- package/dist/contract/policies.d.ts.map +1 -0
- package/dist/contract/policies.js +175 -0
- package/dist/contract/policies.js.map +1 -0
- package/dist/contract/router.d.ts +707 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +127 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/policy/accrual.d.ts +127 -0
- package/dist/policy/accrual.d.ts.map +1 -0
- package/dist/policy/accrual.js +134 -0
- package/dist/policy/accrual.js.map +1 -0
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +186 -1
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +772 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +462 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +612 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +76 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/policies.d.ts +53 -0
- package/dist/server/services/policies.d.ts.map +1 -0
- package/dist/server/services/policies.js +158 -0
- package/dist/server/services/policies.js.map +1 -0
- package/migrations/0004_policies.sql +89 -0
- package/migrations/meta/0004_snapshot.json +4035 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/contract/capabilities.ts +31 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/permissions.ts +28 -0
- package/src/contract/policies.ts +207 -0
- package/src/contract/router.ts +148 -0
package/package.json
CHANGED
|
@@ -74,6 +74,24 @@ export const hrCapabilities = defineCapabilities([
|
|
|
74
74
|
defaultEnabled: true,
|
|
75
75
|
level: 1,
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
id: 'leave_accrual',
|
|
79
|
+
label: 'Accrual',
|
|
80
|
+
description: 'Earn leave over time, with proration, carry-forward and expiry',
|
|
81
|
+
dependsOn: ['leave'],
|
|
82
|
+
// Off by default. Plenty of companies grant a fixed allowance on 1 January and never accrue —
|
|
83
|
+
// and for them an accrual engine is a screen full of settings that change nothing.
|
|
84
|
+
defaultEnabled: false,
|
|
85
|
+
level: 2,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'periods',
|
|
89
|
+
label: 'Payroll periods',
|
|
90
|
+
description: 'Close a month so a filed payroll cannot move underneath it',
|
|
91
|
+
dependsOn: ['core'],
|
|
92
|
+
defaultEnabled: false,
|
|
93
|
+
level: 2,
|
|
94
|
+
},
|
|
77
95
|
{
|
|
78
96
|
id: 'approvals',
|
|
79
97
|
label: 'Approval chains',
|
|
@@ -185,6 +203,19 @@ export const hrCapabilityProcedures: Record<string, readonly string[]> = {
|
|
|
185
203
|
'attendance.regularizations.list',
|
|
186
204
|
'attendance.regularizations.request',
|
|
187
205
|
],
|
|
206
|
+
leave_accrual: [
|
|
207
|
+
'policies.list',
|
|
208
|
+
'policies.get',
|
|
209
|
+
'policies.create',
|
|
210
|
+
'policies.update',
|
|
211
|
+
'policies.archive',
|
|
212
|
+
'policies.assign',
|
|
213
|
+
'policies.unassign',
|
|
214
|
+
'policies.resolveFor',
|
|
215
|
+
'accrual.preview',
|
|
216
|
+
'accrual.run',
|
|
217
|
+
],
|
|
218
|
+
periods: ['periods.list', 'periods.create', 'periods.lock', 'periods.unlock'],
|
|
188
219
|
approvals: [
|
|
189
220
|
'approvals.chains.list',
|
|
190
221
|
'approvals.chains.create',
|
package/src/contract/index.ts
CHANGED
|
@@ -273,6 +273,31 @@ export const hrPermissions = definePermissions([
|
|
|
273
273
|
dangerous: false,
|
|
274
274
|
},
|
|
275
275
|
|
|
276
|
+
// ---------------------------------------------------------------- policies and periods
|
|
277
|
+
{
|
|
278
|
+
key: 'hr.policy.view',
|
|
279
|
+
label: 'View accrual, overtime and rounding policies',
|
|
280
|
+
scope: 'workspace',
|
|
281
|
+
defaultRoles: ['owner', 'admin'],
|
|
282
|
+
dangerous: false,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
key: 'hr.policy.manage',
|
|
286
|
+
label: 'Change policies and who they apply to',
|
|
287
|
+
description: 'Changes what everybody accrues. A retroactive change is an adjustment, not a rewrite.',
|
|
288
|
+
scope: 'workspace',
|
|
289
|
+
defaultRoles: ['owner'],
|
|
290
|
+
dangerous: true,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
key: 'hr.period.manage',
|
|
294
|
+
label: 'Open, lock and unlock payroll periods',
|
|
295
|
+
description: 'Locking freezes a month against recomputation. Unlocking lets it move again.',
|
|
296
|
+
scope: 'workspace',
|
|
297
|
+
defaultRoles: ['owner'],
|
|
298
|
+
dangerous: true,
|
|
299
|
+
},
|
|
300
|
+
|
|
276
301
|
// ---------------------------------------------------------------- approvals
|
|
277
302
|
{
|
|
278
303
|
key: 'hr.approval.manage',
|
|
@@ -334,6 +359,9 @@ export const HR_PERMISSIONS = {
|
|
|
334
359
|
attendanceManage: 'hr.attendance.manage',
|
|
335
360
|
overtimeView: 'hr.overtime.view',
|
|
336
361
|
overtimeManage: 'hr.overtime.manage',
|
|
362
|
+
policyView: 'hr.policy.view',
|
|
363
|
+
policyManage: 'hr.policy.manage',
|
|
364
|
+
periodManage: 'hr.period.manage',
|
|
337
365
|
approvalManage: 'hr.approval.manage',
|
|
338
366
|
approvalDelegate: 'hr.approval.delegate',
|
|
339
367
|
} as const
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Timestamp, WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { IsoDate } from './models.js'
|
|
4
|
+
|
|
5
|
+
const ws = { workspaceId: WorkspaceId }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Policies as data, and the ladder that decides which one applies.
|
|
9
|
+
*
|
|
10
|
+
* Leave entitlement, overtime rules and rounding differ per company and per country, and encoding
|
|
11
|
+
* that as `if (country === 'TR')` is how a product acquires a branch per customer. A policy is a
|
|
12
|
+
* row: a kind, a config validated by that kind's schema, an effective range, and an assignment to
|
|
13
|
+
* somebody — a person, an office, a department, or the whole workspace.
|
|
14
|
+
*
|
|
15
|
+
* **One ladder, used by everything**: `person → primary office → legal entity → org unit →
|
|
16
|
+
* position → workspace`, nearest wins. The same order resolves a calendar, so there is never a
|
|
17
|
+
* second precedence rule to remember.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const PolicyKind = z.enum(['accrual', 'carry_forward', 'overtime', 'rounding', 'working_time'])
|
|
21
|
+
export type PolicyKind = z.infer<typeof PolicyKind>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rungs of the ladder, and the priority each carries.
|
|
25
|
+
*
|
|
26
|
+
* The numbers are the ladder made explicit so a query can order by them rather than a service
|
|
27
|
+
* knowing the sequence by heart.
|
|
28
|
+
*/
|
|
29
|
+
export const PolicySubjectKind = z.enum([
|
|
30
|
+
'person',
|
|
31
|
+
'office',
|
|
32
|
+
'legal_entity',
|
|
33
|
+
'org_unit',
|
|
34
|
+
'position',
|
|
35
|
+
'workspace',
|
|
36
|
+
])
|
|
37
|
+
export type PolicySubjectKind = z.infer<typeof PolicySubjectKind>
|
|
38
|
+
|
|
39
|
+
export const SUBJECT_PRIORITY: Record<PolicySubjectKind, number> = {
|
|
40
|
+
person: 100,
|
|
41
|
+
office: 80,
|
|
42
|
+
legal_entity: 60,
|
|
43
|
+
org_unit: 40,
|
|
44
|
+
position: 30,
|
|
45
|
+
workspace: 0,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------- config per kind
|
|
49
|
+
|
|
50
|
+
export const SeniorityTier = z.object({
|
|
51
|
+
afterYears: z.number().int().min(0).max(60),
|
|
52
|
+
daysPerYear: z.number().min(0).max(365),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const AccrualConfig = z.object({
|
|
56
|
+
frequency: z.enum(['monthly', 'annual', 'anniversary', 'per_hour_worked']),
|
|
57
|
+
daysPerYear: z.number().min(0).max(365),
|
|
58
|
+
minutesPerDay: z.number().int().min(1).max(1440),
|
|
59
|
+
/** Most senior tier reached wins; the order they are written in does not matter. */
|
|
60
|
+
seniorityTiers: z.array(SeniorityTier).default([]),
|
|
61
|
+
waitingPeriodMonths: z.number().int().min(0).max(24).default(0),
|
|
62
|
+
/** Which calendar decides a period boundary. Iran accrues on Jalali months. */
|
|
63
|
+
calendar: z.enum(['gregorian', 'persian']).default('gregorian'),
|
|
64
|
+
roundToMinutes: z.number().int().min(0).max(480).default(0),
|
|
65
|
+
/** Which leave type this accrues into. */
|
|
66
|
+
leaveTypeKey: z.string().min(1).max(48),
|
|
67
|
+
})
|
|
68
|
+
export type AccrualConfig = z.infer<typeof AccrualConfig>
|
|
69
|
+
|
|
70
|
+
export const CarryForwardConfig = z.object({
|
|
71
|
+
leaveTypeKey: z.string().min(1).max(48),
|
|
72
|
+
maxDays: z.number().min(0).max(365),
|
|
73
|
+
/** Months into the new year before carried leave lapses. Null never expires. */
|
|
74
|
+
expiresAfterMonths: z.number().int().min(1).max(24).nullable(),
|
|
75
|
+
})
|
|
76
|
+
export type CarryForwardConfig = z.infer<typeof CarryForwardConfig>
|
|
77
|
+
|
|
78
|
+
export const OvertimeConfig = z.object({
|
|
79
|
+
/** Minutes past the schedule before overtime starts counting. */
|
|
80
|
+
thresholdMinutes: z.number().int().min(0).max(480).default(0),
|
|
81
|
+
/** Cap for the period. Null is uncapped — several jurisdictions cap hours *worked*, not just pay. */
|
|
82
|
+
capMinutesPerYear: z.number().int().min(0).nullable().default(null),
|
|
83
|
+
/** Multiplier for reporting and payroll export. Kern does not compute pay. */
|
|
84
|
+
rate: z.number().min(1).max(5).default(1.5),
|
|
85
|
+
/** Overtime must be approved before it counts towards anything. */
|
|
86
|
+
requiresApproval: z.boolean().default(true),
|
|
87
|
+
/** Convert approved overtime into compensatory leave of this type instead of paying it. */
|
|
88
|
+
compOffLeaveTypeKey: z.string().max(48).nullable().default(null),
|
|
89
|
+
})
|
|
90
|
+
export type OvertimeConfig = z.infer<typeof OvertimeConfig>
|
|
91
|
+
|
|
92
|
+
export const RoundingConfig = z.object({
|
|
93
|
+
stepMinutes: z.number().int().min(0).max(60),
|
|
94
|
+
direction: z.enum(['nearest', 'employee', 'employer']),
|
|
95
|
+
})
|
|
96
|
+
export type RoundingConfig = z.infer<typeof RoundingConfig>
|
|
97
|
+
|
|
98
|
+
export const WorkingTimeConfig = z.object({
|
|
99
|
+
minutesPerDay: z.number().int().min(1).max(1440),
|
|
100
|
+
maxMinutesPerWeek: z.number().int().min(1).max(10080).nullable(),
|
|
101
|
+
/** Minimum rest between shifts, in minutes. Reported, not enforced by refusing a punch. */
|
|
102
|
+
minRestMinutes: z.number().int().min(0).max(1440).nullable(),
|
|
103
|
+
})
|
|
104
|
+
export type WorkingTimeConfig = z.infer<typeof WorkingTimeConfig>
|
|
105
|
+
|
|
106
|
+
/** The config union, keyed by kind. Each kind validates its own shape before it runs. */
|
|
107
|
+
export const PolicyConfig = z.union([
|
|
108
|
+
AccrualConfig,
|
|
109
|
+
CarryForwardConfig,
|
|
110
|
+
OvertimeConfig,
|
|
111
|
+
RoundingConfig,
|
|
112
|
+
WorkingTimeConfig,
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
export const Policy = z.object({
|
|
116
|
+
id: z.uuid(),
|
|
117
|
+
...ws,
|
|
118
|
+
kind: PolicyKind,
|
|
119
|
+
name: z.string().min(1).max(120),
|
|
120
|
+
config: z.record(z.string(), z.unknown()),
|
|
121
|
+
effectiveFrom: IsoDate,
|
|
122
|
+
effectiveTo: IsoDate.nullable(),
|
|
123
|
+
source: z.enum(['pack', 'custom']),
|
|
124
|
+
packKey: z.string().max(32).nullable(),
|
|
125
|
+
/** What a derived row records, so a recomputation can tell a stale figure from a current one. */
|
|
126
|
+
configHash: z.string().max(64),
|
|
127
|
+
archivedAt: Timestamp.nullable(),
|
|
128
|
+
})
|
|
129
|
+
export type Policy = z.infer<typeof Policy>
|
|
130
|
+
|
|
131
|
+
export const PolicyAssignment = z.object({
|
|
132
|
+
id: z.uuid(),
|
|
133
|
+
...ws,
|
|
134
|
+
policyId: z.uuid(),
|
|
135
|
+
subjectKind: PolicySubjectKind,
|
|
136
|
+
/** Null for `workspace`, which needs no id. */
|
|
137
|
+
subjectId: z.uuid().nullable(),
|
|
138
|
+
effectiveFrom: IsoDate,
|
|
139
|
+
effectiveTo: IsoDate.nullable(),
|
|
140
|
+
priority: z.number().int(),
|
|
141
|
+
})
|
|
142
|
+
export type PolicyAssignment = z.infer<typeof PolicyAssignment>
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Which policy of a kind applies to a person on a date, and which rung answered.
|
|
146
|
+
*
|
|
147
|
+
* The rung is not decoration: "why does she accrue differently from her team" is the question this
|
|
148
|
+
* module gets asked, and answering it without a database session is the whole reason the ladder is
|
|
149
|
+
* inspectable.
|
|
150
|
+
*/
|
|
151
|
+
export const ResolvedPolicy = z.object({
|
|
152
|
+
kind: PolicyKind,
|
|
153
|
+
policyId: z.uuid().nullable(),
|
|
154
|
+
policyName: z.string().nullable(),
|
|
155
|
+
config: z.record(z.string(), z.unknown()).nullable(),
|
|
156
|
+
from: PolicySubjectKind.nullable(),
|
|
157
|
+
fromSubjectId: z.uuid().nullable(),
|
|
158
|
+
})
|
|
159
|
+
export type ResolvedPolicy = z.infer<typeof ResolvedPolicy>
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------- periods
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A closed month, and the boundary every recomputation respects.
|
|
165
|
+
*
|
|
166
|
+
* Locking is what makes a filed payroll safe: nothing may silently move a day inside a locked
|
|
167
|
+
* period, so a policy changed with a retroactive `effectiveFrom` produces an adjustment in the open
|
|
168
|
+
* period rather than rewriting history.
|
|
169
|
+
*
|
|
170
|
+
* Per legal entity, because a Dutch entity closes on a different day from a Turkish one.
|
|
171
|
+
*/
|
|
172
|
+
export const Period = z.object({
|
|
173
|
+
id: z.uuid(),
|
|
174
|
+
...ws,
|
|
175
|
+
kind: z.enum(['payroll', 'attendance']),
|
|
176
|
+
legalEntityId: z.uuid().nullable(),
|
|
177
|
+
startsOn: IsoDate,
|
|
178
|
+
endsOn: IsoDate,
|
|
179
|
+
status: z.enum(['open', 'locked']),
|
|
180
|
+
lockedAt: Timestamp.nullable(),
|
|
181
|
+
lockedBy: z.uuid().nullable(),
|
|
182
|
+
note: z.string().max(500).nullable(),
|
|
183
|
+
})
|
|
184
|
+
export type Period = z.infer<typeof Period>
|
|
185
|
+
|
|
186
|
+
/** What an accrual run would do, per person, before it writes anything. */
|
|
187
|
+
export const AccrualPreview = z.object({
|
|
188
|
+
periodFrom: IsoDate,
|
|
189
|
+
periodTo: IsoDate,
|
|
190
|
+
rows: z.array(
|
|
191
|
+
z.object({
|
|
192
|
+
personId: z.uuid(),
|
|
193
|
+
displayName: z.string(),
|
|
194
|
+
leaveTypeId: z.uuid(),
|
|
195
|
+
leaveTypeName: z.string(),
|
|
196
|
+
minutes: z.number().int(),
|
|
197
|
+
days: z.number(),
|
|
198
|
+
/** Why it is that number: entitlement, service, proration, FTE. */
|
|
199
|
+
reason: z.string(),
|
|
200
|
+
/** Already accrued for this period — a re-run must not double-credit. */
|
|
201
|
+
alreadyAccrued: z.boolean(),
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
totalMinutes: z.number().int(),
|
|
205
|
+
skipped: z.array(z.object({ personId: z.uuid(), displayName: z.string(), reason: z.string() })),
|
|
206
|
+
})
|
|
207
|
+
export type AccrualPreview = z.infer<typeof AccrualPreview>
|
package/src/contract/router.ts
CHANGED
|
@@ -53,6 +53,15 @@ import {
|
|
|
53
53
|
TimeZone,
|
|
54
54
|
WorkingWeek,
|
|
55
55
|
} from './models.js'
|
|
56
|
+
import {
|
|
57
|
+
AccrualPreview,
|
|
58
|
+
Period,
|
|
59
|
+
Policy,
|
|
60
|
+
PolicyAssignment,
|
|
61
|
+
PolicyKind,
|
|
62
|
+
PolicySubjectKind,
|
|
63
|
+
ResolvedPolicy,
|
|
64
|
+
} from './policies.js'
|
|
56
65
|
|
|
57
66
|
const ws = z.object({ workspaceId: WorkspaceId })
|
|
58
67
|
const t = ['hr'] as const
|
|
@@ -602,6 +611,145 @@ export const hrContract = {
|
|
|
602
611
|
.output(ok),
|
|
603
612
|
},
|
|
604
613
|
|
|
614
|
+
// ---------------------------------------------------------------- policies
|
|
615
|
+
policies: {
|
|
616
|
+
list: baseContract
|
|
617
|
+
.route({ method: 'GET', path: '/policies', tags: t })
|
|
618
|
+
.input(ws.extend({ kind: PolicyKind.optional(), includeArchived: z.boolean().default(false) }))
|
|
619
|
+
.output(z.array(Policy.extend({ assignments: z.array(PolicyAssignment) }))),
|
|
620
|
+
get: baseContract
|
|
621
|
+
.route({ method: 'GET', path: '/policies/{policyId}', tags: t })
|
|
622
|
+
.input(ws.extend({ policyId: z.uuid() }))
|
|
623
|
+
.output(Policy.extend({ assignments: z.array(PolicyAssignment) })),
|
|
624
|
+
create: baseContract
|
|
625
|
+
.route({ method: 'POST', path: '/policies', tags: t })
|
|
626
|
+
.input(
|
|
627
|
+
ws.extend({
|
|
628
|
+
kind: PolicyKind,
|
|
629
|
+
name: z.string().min(1).max(120),
|
|
630
|
+
/** Validated against the schema for `kind` — a config for the wrong kind is refused. */
|
|
631
|
+
config: z.record(z.string(), z.unknown()),
|
|
632
|
+
effectiveFrom: IsoDate,
|
|
633
|
+
effectiveTo: IsoDate.nullish(),
|
|
634
|
+
}),
|
|
635
|
+
)
|
|
636
|
+
.output(Policy),
|
|
637
|
+
/**
|
|
638
|
+
* Edits a policy in place.
|
|
639
|
+
*
|
|
640
|
+
* A change that should apply from a date rather than retroactively is a **new** policy with a
|
|
641
|
+
* later `effectiveFrom`, not an edit — editing rewrites what was true in the past, and anything
|
|
642
|
+
* already derived from it becomes unexplainable.
|
|
643
|
+
*/
|
|
644
|
+
update: baseContract
|
|
645
|
+
.route({ method: 'PATCH', path: '/policies/{policyId}', tags: t })
|
|
646
|
+
.input(
|
|
647
|
+
ws.extend({
|
|
648
|
+
policyId: z.uuid(),
|
|
649
|
+
name: z.string().min(1).max(120).optional(),
|
|
650
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
651
|
+
effectiveTo: IsoDate.nullish(),
|
|
652
|
+
}),
|
|
653
|
+
)
|
|
654
|
+
.output(Policy),
|
|
655
|
+
archive: baseContract
|
|
656
|
+
.route({ method: 'DELETE', path: '/policies/{policyId}', tags: t })
|
|
657
|
+
.input(ws.extend({ policyId: z.uuid() }))
|
|
658
|
+
.output(ok),
|
|
659
|
+
assign: baseContract
|
|
660
|
+
.route({ method: 'POST', path: '/policies/{policyId}/assign', tags: t })
|
|
661
|
+
.input(
|
|
662
|
+
ws.extend({
|
|
663
|
+
policyId: z.uuid(),
|
|
664
|
+
subjectKind: PolicySubjectKind,
|
|
665
|
+
/** Null for `workspace`, which needs no id. */
|
|
666
|
+
subjectId: z.uuid().nullish(),
|
|
667
|
+
effectiveFrom: IsoDate,
|
|
668
|
+
effectiveTo: IsoDate.nullish(),
|
|
669
|
+
}),
|
|
670
|
+
)
|
|
671
|
+
.output(PolicyAssignment),
|
|
672
|
+
unassign: baseContract
|
|
673
|
+
.route({ method: 'DELETE', path: '/policies/assignments/{assignmentId}', tags: t })
|
|
674
|
+
.input(ws.extend({ assignmentId: z.uuid() }))
|
|
675
|
+
.output(ok),
|
|
676
|
+
/**
|
|
677
|
+
* Which policy of each kind applies to this person on this date, and which rung answered.
|
|
678
|
+
*
|
|
679
|
+
* "Why does she accrue differently from her team" is the question this module gets asked, and
|
|
680
|
+
* this is what answers it without a database session.
|
|
681
|
+
*/
|
|
682
|
+
resolveFor: baseContract
|
|
683
|
+
.route({ method: 'GET', path: '/people/{personId}/policies', tags: t })
|
|
684
|
+
.input(ws.extend({ personId: z.uuid(), on: IsoDate.optional() }))
|
|
685
|
+
.output(z.array(ResolvedPolicy)),
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------- accrual
|
|
689
|
+
accrual: {
|
|
690
|
+
/**
|
|
691
|
+
* What a run would credit, per person, before it writes anything.
|
|
692
|
+
*
|
|
693
|
+
* Runs the same code the run does. A preview computed differently from the thing it previews is
|
|
694
|
+
* a preview that eventually lies.
|
|
695
|
+
*/
|
|
696
|
+
preview: baseContract
|
|
697
|
+
.route({ method: 'POST', path: '/accrual/preview', tags: t })
|
|
698
|
+
.input(ws.extend({ from: IsoDate, to: IsoDate, personId: z.uuid().optional() }))
|
|
699
|
+
.output(AccrualPreview),
|
|
700
|
+
/**
|
|
701
|
+
* Credits the ledger.
|
|
702
|
+
*
|
|
703
|
+
* Idempotent per person, per leave type, per period: a second run for the same window credits
|
|
704
|
+
* nothing, because an accrual job that double-credits when somebody clicks twice is worse than
|
|
705
|
+
* one that never ran.
|
|
706
|
+
*/
|
|
707
|
+
run: baseContract
|
|
708
|
+
.route({ method: 'POST', path: '/accrual/run', tags: t })
|
|
709
|
+
.input(ws.extend({ from: IsoDate, to: IsoDate, personId: z.uuid().optional() }))
|
|
710
|
+
.output(
|
|
711
|
+
z.object({
|
|
712
|
+
credited: z.number().int(),
|
|
713
|
+
skipped: z.number().int(),
|
|
714
|
+
totalMinutes: z.number().int(),
|
|
715
|
+
}),
|
|
716
|
+
),
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
// ---------------------------------------------------------------- periods
|
|
720
|
+
periods: {
|
|
721
|
+
list: baseContract
|
|
722
|
+
.route({ method: 'GET', path: '/periods', tags: t })
|
|
723
|
+
.input(ws.extend({ kind: Period.shape.kind.optional(), ...PageInput.shape }))
|
|
724
|
+
.output(page(Period)),
|
|
725
|
+
create: baseContract
|
|
726
|
+
.route({ method: 'POST', path: '/periods', tags: t })
|
|
727
|
+
.input(
|
|
728
|
+
ws.extend({
|
|
729
|
+
kind: Period.shape.kind.default('payroll'),
|
|
730
|
+
legalEntityId: z.uuid().nullish(),
|
|
731
|
+
startsOn: IsoDate,
|
|
732
|
+
endsOn: IsoDate,
|
|
733
|
+
}),
|
|
734
|
+
)
|
|
735
|
+
.output(Period),
|
|
736
|
+
/** Freezes the month. Every derived day inside it stops being recomputable. */
|
|
737
|
+
lock: baseContract
|
|
738
|
+
.route({ method: 'POST', path: '/periods/{periodId}/lock', tags: t })
|
|
739
|
+
.input(ws.extend({ periodId: z.uuid(), note: z.string().max(500).nullish() }))
|
|
740
|
+
.output(Period.extend({ lockedDays: z.number().int() })),
|
|
741
|
+
/**
|
|
742
|
+
* Reopens it.
|
|
743
|
+
*
|
|
744
|
+
* Dangerous on purpose: a payroll has usually been filed against a locked month, and reopening
|
|
745
|
+
* lets figures move underneath it. The response says how many days became recomputable again.
|
|
746
|
+
*/
|
|
747
|
+
unlock: baseContract
|
|
748
|
+
.route({ method: 'POST', path: '/periods/{periodId}/unlock', tags: t })
|
|
749
|
+
.input(ws.extend({ periodId: z.uuid(), reason: z.string().min(1).max(500) }))
|
|
750
|
+
.output(Period.extend({ unlockedDays: z.number().int() })),
|
|
751
|
+
},
|
|
752
|
+
|
|
605
753
|
// ---------------------------------------------------------------- attendance
|
|
606
754
|
attendance: {
|
|
607
755
|
/** Am I clocked in? The one call a clock widget makes. */
|