@kernhq/module-hr 0.18.1 → 0.20.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 +45 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/exports.d.ts +459 -0
- package/dist/contract/exports.d.ts.map +1 -0
- package/dist/contract/exports.js +331 -0
- package/dist/contract/exports.js.map +1 -0
- package/dist/contract/index.d.ts +2 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +2 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/permissions.d.ts +10 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +44 -5
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/rosters.d.ts +183 -0
- package/dist/contract/rosters.d.ts.map +1 -0
- package/dist/contract/rosters.js +138 -0
- package/dist/contract/rosters.js.map +1 -0
- package/dist/contract/router.d.ts +893 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +220 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +1210 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +578 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +727 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +105 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/exports.d.ts +321 -0
- package/dist/server/services/exports.d.ts.map +1 -0
- package/dist/server/services/exports.js +765 -0
- package/dist/server/services/exports.js.map +1 -0
- package/dist/server/services/rosters.d.ts +166 -0
- package/dist/server/services/rosters.d.ts.map +1 -0
- package/dist/server/services/rosters.js +268 -0
- package/dist/server/services/rosters.js.map +1 -0
- package/migrations/0012_rosters.sql +163 -0
- package/migrations/meta/0012_snapshot.json +4847 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/messages.ts +13 -0
- package/src/contract/capabilities.ts +45 -0
- package/src/contract/exports.ts +354 -0
- package/src/contract/index.ts +2 -0
- package/src/contract/permissions.ts +47 -5
- package/src/contract/rosters.ts +156 -0
- package/src/contract/router.ts +256 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Timestamp, WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { IsoDate, WallClock } from './models.js'
|
|
4
|
+
|
|
5
|
+
const ws = { workspaceId: WorkspaceId }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Rosters: which shift a person works on a **date**.
|
|
9
|
+
*
|
|
10
|
+
* A `Schedule` is a week that repeats for ever, so `ScheduleWeek` is keyed by weekday name. That is
|
|
11
|
+
* the right shape for an office and cannot express a factory: 4-on-4-off has no weekly period at
|
|
12
|
+
* all, and neither does any 2- or 3-week rotation, so there is no length of `ScheduleWeek` that
|
|
13
|
+
* describes one. A roster is keyed by the calendar instead, and adds exactly three things a
|
|
14
|
+
* schedule does not have:
|
|
15
|
+
*
|
|
16
|
+
* - **A shift on a date.** The key is the day, not a modulus of seven.
|
|
17
|
+
* - **Coverage** — "who is on Early on Tuesday" is a question about an office-day, not about a
|
|
18
|
+
* person, and it cannot be asked of a set of weekly schedules without walking everybody's week.
|
|
19
|
+
* - **A one-day exception that survives.** A schedule change is effective-dated and rewrites every
|
|
20
|
+
* day after it; a roster override changes one day and leaves the rotation alone.
|
|
21
|
+
*
|
|
22
|
+
* Everything else attendance already does — grace, rounding, overnight shifts, night-shift business
|
|
23
|
+
* date attribution, the auto-close sweep — stays where it is. A roster is not a second computation
|
|
24
|
+
* of hours; it is a different answer to "what was this person meant to work today".
|
|
25
|
+
*
|
|
26
|
+
* **The rotation is computed, never stored per day.** A `RosterPattern` is a cycle of days and the
|
|
27
|
+
* date `days[0]` falls on; what somebody works on any date at all is arithmetic from those two.
|
|
28
|
+
* Generating a year of rows per person is what makes a roster impossible to change afterwards —
|
|
29
|
+
* moving a crew forward by a day becomes a bulk rewrite of thousands of rows with no way to say
|
|
30
|
+
* which of them a human had touched. Only the exceptions are rows.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A named shift: Early, Late, Night.
|
|
35
|
+
*
|
|
36
|
+
* Named rather than inlined into each pattern because coverage groups by it — "Early" in the
|
|
37
|
+
* warehouse pattern and "Early" in the picking pattern have to be the same column of the same grid.
|
|
38
|
+
* `graceInMinutes` / `graceOutMinutes` sit here rather than on the schedule for the same reason a
|
|
39
|
+
* night shift usually has a wider grace than a day one: they are properties of the shift.
|
|
40
|
+
*/
|
|
41
|
+
export const RosterShift = z.object({
|
|
42
|
+
id: z.uuid(),
|
|
43
|
+
...ws,
|
|
44
|
+
name: z.string().min(1).max(80),
|
|
45
|
+
/** A one- or two-letter code, for a grid too dense to carry a name. */
|
|
46
|
+
code: z.string().max(8).nullable(),
|
|
47
|
+
start: WallClock,
|
|
48
|
+
/** Earlier than `start` for a shift that ends the next morning — the same rule `ShiftSpec` uses. */
|
|
49
|
+
end: WallClock,
|
|
50
|
+
breakMinutes: z.number().int().min(0).max(480),
|
|
51
|
+
graceInMinutes: z.number().int().min(0).max(240),
|
|
52
|
+
graceOutMinutes: z.number().int().min(0).max(240),
|
|
53
|
+
color: z.string().max(32).nullable(),
|
|
54
|
+
archivedAt: Timestamp.nullable(),
|
|
55
|
+
})
|
|
56
|
+
export type RosterShift = z.infer<typeof RosterShift>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One position in a rotation: the shifts worked on that day of the cycle.
|
|
60
|
+
*
|
|
61
|
+
* An **array**, so a split shift — 06:00–10:00 and 16:00–20:00, ordinary in hospitality — is a
|
|
62
|
+
* cycle day with two entries rather than something the model forbids. An empty array is a rest day,
|
|
63
|
+
* which is a different fact from "nothing is rostered": a rest day is planned.
|
|
64
|
+
*/
|
|
65
|
+
export const RosterCycleDay = z.array(z.uuid()).max(4)
|
|
66
|
+
export type RosterCycleDay = z.infer<typeof RosterCycleDay>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A rotation, as a cycle and the date it starts from.
|
|
70
|
+
*
|
|
71
|
+
* The cycle length is `days.length` and is not stored separately — two numbers that must agree is
|
|
72
|
+
* one number and a bug. `anchorDate` is the date `days[0]` applies to; every other date is
|
|
73
|
+
* `(dayNumber(date) - dayNumber(anchorDate) + cycleOffset) mod days.length`, which answers for
|
|
74
|
+
* dates before the anchor as readily as after it.
|
|
75
|
+
*/
|
|
76
|
+
export const RosterPattern = z.object({
|
|
77
|
+
id: z.uuid(),
|
|
78
|
+
...ws,
|
|
79
|
+
name: z.string().min(1).max(120),
|
|
80
|
+
/** The date `days[0]` falls on. Moving it rotates every assignment on this pattern at once. */
|
|
81
|
+
anchorDate: IsoDate,
|
|
82
|
+
days: z.array(RosterCycleDay).min(1).max(56),
|
|
83
|
+
archivedAt: Timestamp.nullable(),
|
|
84
|
+
})
|
|
85
|
+
export type RosterPattern = z.infer<typeof RosterPattern>
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A person on a rotation, over a period.
|
|
89
|
+
*
|
|
90
|
+
* Effective-dated like every other assignment here, and at most one may be in force on a day — the
|
|
91
|
+
* exclusion constraint in migration 0012 is what guarantees that rather than the handler, because
|
|
92
|
+
* two concurrent requests cannot both win against a constraint.
|
|
93
|
+
*
|
|
94
|
+
* `cycleOffset` is what puts two crews on one pattern out of phase: crew B on a 4-on-4-off cycle
|
|
95
|
+
* with `cycleOffset: 4` works exactly the days crew A is off. Without it every crew needs its own
|
|
96
|
+
* copy of the same rotation, and a change to the rotation has to be made once per crew.
|
|
97
|
+
*/
|
|
98
|
+
export const RosterAssignment = z.object({
|
|
99
|
+
id: z.uuid(),
|
|
100
|
+
...ws,
|
|
101
|
+
personId: z.uuid(),
|
|
102
|
+
patternId: z.uuid(),
|
|
103
|
+
effectiveFrom: IsoDate,
|
|
104
|
+
effectiveTo: IsoDate.nullable(),
|
|
105
|
+
cycleOffset: z.number().int().min(0).max(55),
|
|
106
|
+
createdAt: Timestamp,
|
|
107
|
+
})
|
|
108
|
+
export type RosterAssignment = z.infer<typeof RosterAssignment>
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Where a rostered day came from.
|
|
112
|
+
*
|
|
113
|
+
* `none` is not the same as a rest day: it means nothing rosters this person on this date at all,
|
|
114
|
+
* and a screen that renders it identically to a planned day off is telling somebody their absence
|
|
115
|
+
* was intended. A rest day is `pattern` with no shifts.
|
|
116
|
+
*/
|
|
117
|
+
export const RosterDaySource = z.enum(['pattern', 'override', 'none'])
|
|
118
|
+
export type RosterDaySource = z.infer<typeof RosterDaySource>
|
|
119
|
+
|
|
120
|
+
export const RosterDay = z.object({
|
|
121
|
+
personId: z.uuid(),
|
|
122
|
+
businessDate: IsoDate,
|
|
123
|
+
shifts: z.array(RosterShift),
|
|
124
|
+
source: RosterDaySource,
|
|
125
|
+
/** Why this day differs from the rotation. Only ever set on an override. */
|
|
126
|
+
note: z.string().max(500).nullable(),
|
|
127
|
+
})
|
|
128
|
+
export type RosterDay = z.infer<typeof RosterDay>
|
|
129
|
+
|
|
130
|
+
/** Enough of a person to fill a coverage grid. The name every member may already read. */
|
|
131
|
+
export const RosterPerson = z.object({ personId: z.uuid(), displayName: z.string() })
|
|
132
|
+
export type RosterPerson = z.infer<typeof RosterPerson>
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One office-day, which is the question a roster exists to answer.
|
|
136
|
+
*
|
|
137
|
+
* `off` carries the people a pattern covers on this date and does not put on a shift — the answer
|
|
138
|
+
* to "who could I call in", which is the second thing anybody looking at a coverage grid wants and
|
|
139
|
+
* the one a list of who is working cannot give.
|
|
140
|
+
*/
|
|
141
|
+
export const RosterCoverageDay = z.object({
|
|
142
|
+
businessDate: IsoDate,
|
|
143
|
+
slots: z.array(z.object({ shift: RosterShift, people: z.array(RosterPerson) })),
|
|
144
|
+
off: z.array(RosterPerson),
|
|
145
|
+
})
|
|
146
|
+
export type RosterCoverageDay = z.infer<typeof RosterCoverageDay>
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How long a roster range may be.
|
|
150
|
+
*
|
|
151
|
+
* Expansion is arithmetic and cheap; the cost is the person-days a coverage grid resolves and the
|
|
152
|
+
* size of what comes back. A quarter covers the longest rotation anybody plans by hand, and
|
|
153
|
+
* coverage is capped tighter because it multiplies by the population of an office.
|
|
154
|
+
*/
|
|
155
|
+
export const MAX_ROSTER_DAYS = 186
|
|
156
|
+
export const MAX_COVERAGE_DAYS = 42
|
package/src/contract/router.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
ScheduleAssignment,
|
|
19
19
|
ScheduleWeek,
|
|
20
20
|
} from './attendance.js'
|
|
21
|
+
import { PayrollExport, PayrollExportPreview } from './exports.js'
|
|
21
22
|
import {
|
|
22
23
|
DayPart,
|
|
23
24
|
LeaveBalance,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
RegionCode,
|
|
52
53
|
ResolvedCalendarDay,
|
|
53
54
|
TimeZone,
|
|
55
|
+
WallClock,
|
|
54
56
|
WorkingWeek,
|
|
55
57
|
} from './models.js'
|
|
56
58
|
import {
|
|
@@ -76,6 +78,14 @@ import {
|
|
|
76
78
|
OvertimeReport,
|
|
77
79
|
ReportSliceBy,
|
|
78
80
|
} from './reports.js'
|
|
81
|
+
import {
|
|
82
|
+
RosterAssignment,
|
|
83
|
+
RosterCoverageDay,
|
|
84
|
+
RosterCycleDay,
|
|
85
|
+
RosterDay,
|
|
86
|
+
RosterPattern,
|
|
87
|
+
RosterShift,
|
|
88
|
+
} from './rosters.js'
|
|
79
89
|
|
|
80
90
|
const ws = z.object({ workspaceId: WorkspaceId })
|
|
81
91
|
const t = ['hr'] as const
|
|
@@ -973,6 +983,180 @@ export const hrContract = {
|
|
|
973
983
|
},
|
|
974
984
|
},
|
|
975
985
|
|
|
986
|
+
// ---------------------------------------------------------------- rosters
|
|
987
|
+
/**
|
|
988
|
+
* Who works which shift on which **date**.
|
|
989
|
+
*
|
|
990
|
+
* Separate from `attendance.schedules` rather than folded into it, because the two answer
|
|
991
|
+
* different questions and only one of them has a weekly period. A schedule is a week that repeats
|
|
992
|
+
* for ever; a rotation is a cycle of any length anchored to a date, which is the only shape
|
|
993
|
+
* 4-on-4-off has. Nothing here recomputes hours — grace, rounding, overnight attribution and the
|
|
994
|
+
* auto-close sweep all stay in attendance, and a roster only decides what the day was meant to be.
|
|
995
|
+
*/
|
|
996
|
+
rosters: {
|
|
997
|
+
shifts: {
|
|
998
|
+
list: baseContract
|
|
999
|
+
.route({ method: 'GET', path: '/rosters/shifts', tags: t })
|
|
1000
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
1001
|
+
.output(z.array(RosterShift)),
|
|
1002
|
+
create: baseContract
|
|
1003
|
+
.route({ method: 'POST', path: '/rosters/shifts', tags: t })
|
|
1004
|
+
.input(
|
|
1005
|
+
ws.extend({
|
|
1006
|
+
name: z.string().min(1).max(80),
|
|
1007
|
+
code: z.string().max(8).nullish(),
|
|
1008
|
+
start: WallClock,
|
|
1009
|
+
end: WallClock,
|
|
1010
|
+
breakMinutes: z.number().int().min(0).max(480).default(0),
|
|
1011
|
+
graceInMinutes: z.number().int().min(0).max(240).default(0),
|
|
1012
|
+
graceOutMinutes: z.number().int().min(0).max(240).default(0),
|
|
1013
|
+
color: z.string().max(32).nullish(),
|
|
1014
|
+
}),
|
|
1015
|
+
)
|
|
1016
|
+
.output(RosterShift),
|
|
1017
|
+
update: baseContract
|
|
1018
|
+
.route({ method: 'PATCH', path: '/rosters/shifts/{shiftId}', tags: t })
|
|
1019
|
+
.input(
|
|
1020
|
+
ws.extend({
|
|
1021
|
+
shiftId: z.uuid(),
|
|
1022
|
+
name: z.string().min(1).max(80).optional(),
|
|
1023
|
+
code: z.string().max(8).nullish(),
|
|
1024
|
+
start: WallClock.optional(),
|
|
1025
|
+
end: WallClock.optional(),
|
|
1026
|
+
breakMinutes: z.number().int().min(0).max(480).optional(),
|
|
1027
|
+
graceInMinutes: z.number().int().min(0).max(240).optional(),
|
|
1028
|
+
graceOutMinutes: z.number().int().min(0).max(240).optional(),
|
|
1029
|
+
color: z.string().max(32).nullish(),
|
|
1030
|
+
}),
|
|
1031
|
+
)
|
|
1032
|
+
.output(RosterShift),
|
|
1033
|
+
/**
|
|
1034
|
+
* Archive, never delete. Patterns and stored overrides point at a shift by id, so deleting
|
|
1035
|
+
* one would empty out every day it appears on — past days included.
|
|
1036
|
+
*/
|
|
1037
|
+
archive: baseContract
|
|
1038
|
+
.route({ method: 'DELETE', path: '/rosters/shifts/{shiftId}', tags: t })
|
|
1039
|
+
.input(ws.extend({ shiftId: z.uuid() }))
|
|
1040
|
+
.output(ok),
|
|
1041
|
+
},
|
|
1042
|
+
|
|
1043
|
+
patterns: {
|
|
1044
|
+
list: baseContract
|
|
1045
|
+
.route({ method: 'GET', path: '/rosters/patterns', tags: t })
|
|
1046
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
1047
|
+
.output(z.array(RosterPattern)),
|
|
1048
|
+
create: baseContract
|
|
1049
|
+
.route({ method: 'POST', path: '/rosters/patterns', tags: t })
|
|
1050
|
+
.input(
|
|
1051
|
+
ws.extend({
|
|
1052
|
+
name: z.string().min(1).max(120),
|
|
1053
|
+
anchorDate: IsoDate,
|
|
1054
|
+
days: z.array(RosterCycleDay).min(1).max(56),
|
|
1055
|
+
}),
|
|
1056
|
+
)
|
|
1057
|
+
.output(RosterPattern),
|
|
1058
|
+
/**
|
|
1059
|
+
* Editing a rotation moves every crew on it, on every date, at once — which is the point of a
|
|
1060
|
+
* rotation being computed rather than generated, and worth saying out loud on the screen that
|
|
1061
|
+
* does it.
|
|
1062
|
+
*/
|
|
1063
|
+
update: baseContract
|
|
1064
|
+
.route({ method: 'PATCH', path: '/rosters/patterns/{patternId}', tags: t })
|
|
1065
|
+
.input(
|
|
1066
|
+
ws.extend({
|
|
1067
|
+
patternId: z.uuid(),
|
|
1068
|
+
name: z.string().min(1).max(120).optional(),
|
|
1069
|
+
anchorDate: IsoDate.optional(),
|
|
1070
|
+
days: z.array(RosterCycleDay).min(1).max(56).optional(),
|
|
1071
|
+
}),
|
|
1072
|
+
)
|
|
1073
|
+
.output(RosterPattern),
|
|
1074
|
+
archive: baseContract
|
|
1075
|
+
.route({ method: 'DELETE', path: '/rosters/patterns/{patternId}', tags: t })
|
|
1076
|
+
.input(ws.extend({ patternId: z.uuid() }))
|
|
1077
|
+
.output(ok),
|
|
1078
|
+
},
|
|
1079
|
+
|
|
1080
|
+
/** Who is on which rotation, and when. `attendance.schedules` has no equivalent and should. */
|
|
1081
|
+
assignments: baseContract
|
|
1082
|
+
.route({ method: 'GET', path: '/rosters/assignments', tags: t })
|
|
1083
|
+
.input(ws.extend({ personId: z.uuid().optional(), patternId: z.uuid().optional() }))
|
|
1084
|
+
.output(z.array(RosterAssignment)),
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Put a crew on a rotation.
|
|
1088
|
+
*
|
|
1089
|
+
* Takes a list of people because a rotation is a crew's, not a person's — assigning eleven
|
|
1090
|
+
* people one at a time is eleven chances to get the offset wrong. The previous assignment is
|
|
1091
|
+
* closed the day before, so "which rotation was she on in March" stays answerable.
|
|
1092
|
+
*/
|
|
1093
|
+
assign: baseContract
|
|
1094
|
+
.route({ method: 'POST', path: '/rosters/assign', tags: t })
|
|
1095
|
+
.input(
|
|
1096
|
+
ws.extend({
|
|
1097
|
+
patternId: z.uuid(),
|
|
1098
|
+
personIds: z.array(z.uuid()).min(1).max(200),
|
|
1099
|
+
effectiveFrom: IsoDate,
|
|
1100
|
+
/** The last day covered, inclusive. Null leaves the assignment open. */
|
|
1101
|
+
effectiveTo: IsoDate.nullish(),
|
|
1102
|
+
/** Which position of the cycle `effectiveFrom` starts at — how two crews run out of phase. */
|
|
1103
|
+
cycleOffset: z.number().int().min(0).max(55).default(0),
|
|
1104
|
+
}),
|
|
1105
|
+
)
|
|
1106
|
+
.output(z.array(RosterAssignment)),
|
|
1107
|
+
|
|
1108
|
+
/** Take a crew off its rotation, from the day after `effectiveTo`. Nothing is deleted. */
|
|
1109
|
+
unassign: baseContract
|
|
1110
|
+
.route({ method: 'POST', path: '/rosters/unassign', tags: t })
|
|
1111
|
+
.input(
|
|
1112
|
+
ws.extend({
|
|
1113
|
+
personIds: z.array(z.uuid()).min(1).max(200),
|
|
1114
|
+
/** The last day the rotation still applies. */
|
|
1115
|
+
effectiveTo: IsoDate,
|
|
1116
|
+
}),
|
|
1117
|
+
)
|
|
1118
|
+
.output(z.object({ closed: z.number().int() })),
|
|
1119
|
+
|
|
1120
|
+
/** One person's roster over a range, rotation and overrides already resolved. */
|
|
1121
|
+
days: baseContract
|
|
1122
|
+
.route({ method: 'GET', path: '/rosters/days', tags: t })
|
|
1123
|
+
.input(ws.extend({ personId: z.uuid().optional(), from: IsoDate, to: IsoDate }))
|
|
1124
|
+
.output(z.array(RosterDay)),
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Change one day, leaving the rotation alone.
|
|
1128
|
+
*
|
|
1129
|
+
* An empty `shiftIds` is "off that day" and is stored — a planned rest day and a day nothing
|
|
1130
|
+
* rosters at all are different facts, and only one of them is somebody's decision.
|
|
1131
|
+
*/
|
|
1132
|
+
set: baseContract
|
|
1133
|
+
.route({ method: 'POST', path: '/rosters/days', tags: t })
|
|
1134
|
+
.input(
|
|
1135
|
+
ws.extend({
|
|
1136
|
+
personId: z.uuid(),
|
|
1137
|
+
businessDate: IsoDate,
|
|
1138
|
+
shiftIds: RosterCycleDay,
|
|
1139
|
+
note: z.string().max(500).nullish(),
|
|
1140
|
+
}),
|
|
1141
|
+
)
|
|
1142
|
+
.output(RosterDay),
|
|
1143
|
+
|
|
1144
|
+
/** Drop the exception and let the rotation speak for that day again. */
|
|
1145
|
+
clear: baseContract
|
|
1146
|
+
.route({ method: 'DELETE', path: '/rosters/days', tags: t })
|
|
1147
|
+
.input(ws.extend({ personId: z.uuid(), businessDate: IsoDate }))
|
|
1148
|
+
.output(ok),
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Who is on which shift, per day — the question a roster exists for and the one a set of weekly
|
|
1152
|
+
* schedules cannot be asked without walking everybody's week.
|
|
1153
|
+
*/
|
|
1154
|
+
coverage: baseContract
|
|
1155
|
+
.route({ method: 'GET', path: '/rosters/coverage', tags: t })
|
|
1156
|
+
.input(ws.extend({ from: IsoDate, to: IsoDate, officeId: z.uuid().optional() }))
|
|
1157
|
+
.output(z.array(RosterCoverageDay)),
|
|
1158
|
+
},
|
|
1159
|
+
|
|
976
1160
|
// ---------------------------------------------------------------- leave
|
|
977
1161
|
leave: {
|
|
978
1162
|
types: {
|
|
@@ -1382,6 +1566,78 @@ export const hrContract = {
|
|
|
1382
1566
|
.output(LeaveBalanceReport),
|
|
1383
1567
|
},
|
|
1384
1568
|
|
|
1569
|
+
// ---------------------------------------------------------------- payroll export
|
|
1570
|
+
/**
|
|
1571
|
+
* The monthly handover to whoever runs payroll, per legal entity, frozen at v1.
|
|
1572
|
+
*
|
|
1573
|
+
* `exports.ts` carries the reasoning; three things about the *shape of the surface* belong here:
|
|
1574
|
+
*
|
|
1575
|
+
* **`legalEntityId` is required, and `periodId` is the range.** `reportInput` makes its slice
|
|
1576
|
+
* optional with a `workspace` default and takes free `from`/`to` dates; neither is available here.
|
|
1577
|
+
* A workspace is not an employer, and a half-month export is a question about a boundary this
|
|
1578
|
+
* module already has an answer for — letting a caller draw their own reintroduces the mixed-finality
|
|
1579
|
+
* problem `ReportFinality` exists to name.
|
|
1580
|
+
*
|
|
1581
|
+
* **`v1` is a procedure, not a parameter.** A later column set ships as `payroll.export.v2` beside
|
|
1582
|
+
* this one, with this one unchanged, and both live through at least one deprecation window. A
|
|
1583
|
+
* `?version=` on one mutable procedure would make the frozen path a branch inside a function three
|
|
1584
|
+
* people will edit, and freezing by intention has never worked.
|
|
1585
|
+
*
|
|
1586
|
+
* **Both cost three keys.** `hr.payroll.export`, which ships granted to nobody, plus
|
|
1587
|
+
* `hr.attendance.view_team` for the hours file and `hr.leave.view_team` for the leave file — the
|
|
1588
|
+
* same second-check rule the reports follow, because an export must not answer what the row-level
|
|
1589
|
+
* procedure would refuse.
|
|
1590
|
+
*/
|
|
1591
|
+
payroll: {
|
|
1592
|
+
export: {
|
|
1593
|
+
/**
|
|
1594
|
+
* One entity, one period, two CSVs and a manifest.
|
|
1595
|
+
*
|
|
1596
|
+
* Refuses an open period unless `draft` is set, refuses an entity with nobody in it, and
|
|
1597
|
+
* refuses a person with no employment row covering their days here — because a row of zeros is
|
|
1598
|
+
* something a payroll clerk will pay from, and an error is not.
|
|
1599
|
+
*/
|
|
1600
|
+
v1: baseContract
|
|
1601
|
+
.route({ method: 'GET', path: '/payroll/export/v1', tags: t })
|
|
1602
|
+
.input(
|
|
1603
|
+
ws.extend({
|
|
1604
|
+
/** Required. There is no workspace-wide export: a workspace is not an employer. */
|
|
1605
|
+
legalEntityId: z.uuid(),
|
|
1606
|
+
/** The range is the period's, never the caller's. */
|
|
1607
|
+
periodId: z.uuid(),
|
|
1608
|
+
/**
|
|
1609
|
+
* Export an open period anyway, and stamp the file as a draft.
|
|
1610
|
+
*
|
|
1611
|
+
* Not a permission — a statement the caller made, which the file then repeats in its
|
|
1612
|
+
* manifest, its filename and its `open_days`. Defaults to false: `reconcile-days` rebuilds
|
|
1613
|
+
* every day a period does not close, so the same export at 18:00 and at 09:00 the next
|
|
1614
|
+
* morning can differ with nobody having touched anything.
|
|
1615
|
+
*/
|
|
1616
|
+
draft: z.boolean().default(false),
|
|
1617
|
+
}),
|
|
1618
|
+
)
|
|
1619
|
+
.output(PayrollExport),
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* The same rows as JSON, with no file written and no refusal thrown.
|
|
1623
|
+
*
|
|
1624
|
+
* So the screen shows the totals and the reasons the export would be refused before anybody
|
|
1625
|
+
* downloads anything. Deliberately not versioned: a preview is this module talking to its own
|
|
1626
|
+
* screen, and nothing outside Kern parses it.
|
|
1627
|
+
*/
|
|
1628
|
+
preview: baseContract
|
|
1629
|
+
.route({ method: 'GET', path: '/payroll/export/preview', tags: t })
|
|
1630
|
+
.input(
|
|
1631
|
+
ws.extend({
|
|
1632
|
+
legalEntityId: z.uuid(),
|
|
1633
|
+
periodId: z.uuid(),
|
|
1634
|
+
draft: z.boolean().default(false),
|
|
1635
|
+
}),
|
|
1636
|
+
)
|
|
1637
|
+
.output(PayrollExportPreview),
|
|
1638
|
+
},
|
|
1639
|
+
},
|
|
1640
|
+
|
|
1385
1641
|
// ---------------------------------------------------------------- privacy
|
|
1386
1642
|
privacy: {
|
|
1387
1643
|
/**
|