@kernhq/module-hr 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
package/dist/server/jobs.js
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
import { and, eq, inArray, isNull, lte, sql } from 'drizzle-orm';
|
|
2
|
-
import {
|
|
1
|
+
import { and, eq, gt, gte, inArray, isNull, lte, notExists, sql } from 'drizzle-orm';
|
|
2
|
+
import { alias } from 'drizzle-orm/pg-core';
|
|
3
|
+
import { accrueForPeriod, carryExpiryDate, carryForward, carryHasLapsed } from '../policy/accrual.js';
|
|
4
|
+
import { daysInMonth } from '../policy/calendar.js';
|
|
3
5
|
import { attendanceDays, employments, leaveLedger, leaveTypes, offices, people, punches, schedules, } from './schema.js';
|
|
4
6
|
import { AttendanceService } from './services/attendance.js';
|
|
5
7
|
import { todayIn } from './services/db.js';
|
|
6
8
|
import { LedgerService } from './services/ledger.js';
|
|
7
9
|
import { PolicyService } from './services/policies.js';
|
|
8
10
|
import { ResolveService } from './services/resolve.js';
|
|
11
|
+
/**
|
|
12
|
+
* How far back the auto clock-out sweep looks.
|
|
13
|
+
*
|
|
14
|
+
* Comfortably longer than any `auto_clock_out_after_minutes` a schedule configures, and long enough
|
|
15
|
+
* that the job having been down over a weekend still closes what it missed.
|
|
16
|
+
*/
|
|
17
|
+
const AUTO_CLOCK_OUT_LOOKBACK_DAYS = 3;
|
|
9
18
|
/**
|
|
10
19
|
* HR's scheduled work.
|
|
11
20
|
*
|
|
12
21
|
* One rule runs through all of it: **a cron expression fires in UTC, and this module's users do
|
|
13
22
|
* not live there.** A nightly job at 00:00 UTC closes an Amsterdam shift at 01:00 and an Istanbul
|
|
14
23
|
* one at 03:00, and gets Tehran's half-hour offset wrong in a way nobody would ever guess from the
|
|
15
|
-
* code. So the jobs run hourly and fan out per
|
|
16
|
-
* local boundary has passed.
|
|
24
|
+
* code. So the two calendar jobs — `accrue-leave` and `carry-forward` — run hourly and fan out per
|
|
25
|
+
* office, asking each whether *that office's* local boundary has passed.
|
|
26
|
+
*
|
|
27
|
+
* That was the comment for months while no handler read the offices table: `accrue-leave` fired
|
|
28
|
+
* once a month and asked Postgres for `date_trunc('month', now())`, which is the database session's
|
|
29
|
+
* timezone, so a New York office was credited January's accrual at 21:00 on 31 January local. A
|
|
30
|
+
* boundary is a date in a zone, so it is `todayIn(office.timezone)` and nothing else.
|
|
31
|
+
*
|
|
32
|
+
* Running hourly means a handler is entered twenty-four times for every boundary it acts on, so
|
|
33
|
+
* each is idempotent by construction rather than by scheduling: the ledger is asked what it already
|
|
34
|
+
* holds before anything is written.
|
|
35
|
+
*
|
|
36
|
+
* `auto-clock-out` is the exception, and says so where it lives: an elapsed hour is the same hour in
|
|
37
|
+
* every zone, so an office decides which *zone* its punch is stamped in, not when the sweep fires.
|
|
17
38
|
*/
|
|
18
39
|
export function hrJobs() {
|
|
19
40
|
return [
|
|
@@ -49,183 +70,302 @@ export function hrJobs() {
|
|
|
49
70
|
},
|
|
50
71
|
{
|
|
51
72
|
/**
|
|
52
|
-
* Monthly accrual.
|
|
73
|
+
* Monthly accrual, on each office's own first of the month.
|
|
53
74
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
75
|
+
* Hourly rather than monthly, because "the month has ended" is a different instant in Istanbul
|
|
76
|
+
* and in New York and a cron expression only knows UTC. Every office is asked what date it is
|
|
77
|
+
* standing on; the ones that have reached the 1st accrue the month behind them.
|
|
78
|
+
*
|
|
79
|
+
* Offices that turned the same month are one accrual, not one each: what a period grants
|
|
80
|
+
* depends on the period, and the office decides only *when* that period ended. Which is also
|
|
81
|
+
* why the month's bounds are arithmetic here rather than `date_trunc('month', now())` — that
|
|
82
|
+
* asked the database session's timezone, which is an accident of deployment.
|
|
57
83
|
*
|
|
58
84
|
* It writes through the same path `accrual.run` uses, so a scheduled credit and a manual one
|
|
59
85
|
* are the same operation and cannot drift. Idempotent per person, per type, per period: a
|
|
60
|
-
* retry after a partial failure
|
|
86
|
+
* retry after a partial failure, and the twenty-three further ticks of the same local day,
|
|
87
|
+
* credit only what is missing.
|
|
61
88
|
*/
|
|
62
89
|
name: 'accrue-leave',
|
|
63
|
-
cron: '0
|
|
90
|
+
cron: '0 * * * *',
|
|
64
91
|
handler: async (_input, { kernel }) => {
|
|
65
92
|
const resolve = new ResolveService();
|
|
66
93
|
const policySvc = new PolicyService(resolve);
|
|
67
94
|
const ledger = new LedgerService();
|
|
95
|
+
const at = new Date();
|
|
68
96
|
for (const workspaceId of await activeWorkspaces(kernel))
|
|
69
97
|
await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
const turned = new Map();
|
|
99
|
+
for (const office of await officeDays(tx, workspaceId, at)) {
|
|
100
|
+
if (!office.today.endsWith('-01'))
|
|
101
|
+
continue;
|
|
102
|
+
const { from, to } = monthBefore(office.today);
|
|
103
|
+
const group = turned.get(to) ?? { from, to, officeIds: new Set() };
|
|
104
|
+
group.officeIds.add(office.id);
|
|
105
|
+
turned.set(to, group);
|
|
106
|
+
}
|
|
107
|
+
if (!turned.size)
|
|
78
108
|
return;
|
|
79
|
-
const { from, to } = previous;
|
|
80
109
|
const staff = await tx
|
|
81
110
|
.select()
|
|
82
111
|
.from(people)
|
|
83
112
|
.where(and(eq(people.workspaceId, workspaceId), inArray(people.status, ['active', 'on_leave'])));
|
|
84
113
|
if (!staff.length)
|
|
85
114
|
return;
|
|
86
|
-
const
|
|
87
|
-
const resolved = await policySvc.forPeople(tx, workspaceId, ids, 'accrual', to);
|
|
115
|
+
const everyone = staff.map((p) => p.id);
|
|
88
116
|
const types = await tx
|
|
89
117
|
.select()
|
|
90
118
|
.from(leaveTypes)
|
|
91
119
|
.where(and(eq(leaveTypes.workspaceId, workspaceId), isNull(leaveTypes.archivedAt)));
|
|
92
120
|
const typeByKey = new Map(types.map((t) => [t.key, t]));
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const already = new Set((await tx
|
|
99
|
-
.select({ personId: leaveLedger.personId, leaveTypeId: leaveLedger.leaveTypeId })
|
|
100
|
-
.from(leaveLedger)
|
|
101
|
-
.where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.kind, 'accrual'), eq(leaveLedger.effectiveOn, to), inArray(leaveLedger.personId, ids)))).map((e) => `${e.personId}:${e.leaveTypeId}`));
|
|
102
|
-
let credited = 0;
|
|
103
|
-
for (const person of staff) {
|
|
104
|
-
const policy = resolved.get(person.id);
|
|
105
|
-
if (!policy?.config || !person.hiredOn)
|
|
106
|
-
continue;
|
|
107
|
-
const config = policy.config;
|
|
108
|
-
const type = typeByKey.get(config.leaveTypeKey);
|
|
109
|
-
if (!type || already.has(`${person.id}:${type.id}`))
|
|
110
|
-
continue;
|
|
111
|
-
const employment = employmentBy.get(person.id);
|
|
112
|
-
const result = accrueForPeriod({
|
|
113
|
-
policy: {
|
|
114
|
-
frequency: config.frequency,
|
|
115
|
-
daysPerYear: config.daysPerYear,
|
|
116
|
-
minutesPerDay: config.minutesPerDay,
|
|
117
|
-
seniorityTiers: config.seniorityTiers,
|
|
118
|
-
waitingPeriodMonths: config.waitingPeriodMonths,
|
|
119
|
-
roundToMinutes: config.roundToMinutes,
|
|
120
|
-
},
|
|
121
|
-
period: { from, to },
|
|
122
|
-
hiredOn: person.hiredOn,
|
|
123
|
-
terminatedOn: person.terminatedOn,
|
|
124
|
-
fte: employment ? Number.parseFloat(employment.fte ?? '1') : 1,
|
|
125
|
-
});
|
|
126
|
-
if (result.minutes <= 0)
|
|
121
|
+
for (const { from, to, officeIds } of turned.values()) {
|
|
122
|
+
// Resolved on the last day of the period, not today: somebody who transferred in
|
|
123
|
+
// January accrues January against the office that actually employed them for it.
|
|
124
|
+
const ids = await inOffices(resolve, tx, workspaceId, everyone, to, officeIds);
|
|
125
|
+
if (!ids.length)
|
|
127
126
|
continue;
|
|
128
|
-
const
|
|
129
|
-
await
|
|
130
|
-
await
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
127
|
+
const mine = new Set(ids);
|
|
128
|
+
const resolved = await policySvc.forPeople(tx, workspaceId, ids, 'accrual', to);
|
|
129
|
+
const employmentRows = await tx
|
|
130
|
+
.select()
|
|
131
|
+
.from(employments)
|
|
132
|
+
.where(and(eq(employments.workspaceId, workspaceId), inArray(employments.personId, ids), isNull(employments.effectiveTo)));
|
|
133
|
+
const employmentBy = new Map(employmentRows.map((e) => [e.personId, e]));
|
|
134
|
+
const already = new Set((await tx
|
|
135
|
+
.select({ personId: leaveLedger.personId, leaveTypeId: leaveLedger.leaveTypeId })
|
|
136
|
+
.from(leaveLedger)
|
|
137
|
+
.where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.kind, 'accrual'), eq(leaveLedger.effectiveOn, to), inArray(leaveLedger.personId, ids)))).map((e) => `${e.personId}:${e.leaveTypeId}`));
|
|
138
|
+
/**
|
|
139
|
+
* Hours worked in the period, for the one frequency that accrues against them.
|
|
140
|
+
*
|
|
141
|
+
* `per_hour_worked` was implemented in the pure layer and fed by nobody: the two
|
|
142
|
+
* callers passed neither figure, so `grantForPeriod` took its "nothing scheduled"
|
|
143
|
+
* guard and returned zero for every person, and the job's `minutes <= 0` skipped them
|
|
144
|
+
* without a word. The numbers were in `attendance_days` the whole time.
|
|
145
|
+
*
|
|
146
|
+
* Only queried where a policy actually asks for it — the other three frequencies
|
|
147
|
+
* accrue against the calendar, and a sum over a month of day sheets for everybody is
|
|
148
|
+
* not free.
|
|
149
|
+
*/
|
|
150
|
+
const worksAgainstHours = [...resolved.values()].some((p) => p?.config?.frequency === 'per_hour_worked');
|
|
151
|
+
const hoursBy = new Map();
|
|
152
|
+
if (worksAgainstHours) {
|
|
153
|
+
const rows = await tx
|
|
154
|
+
.select({
|
|
155
|
+
personId: attendanceDays.personId,
|
|
156
|
+
worked: sql `coalesce(sum(${attendanceDays.workedMinutes}), 0)`,
|
|
157
|
+
scheduled: sql `coalesce(sum(${attendanceDays.scheduledMinutes}), 0)`,
|
|
158
|
+
})
|
|
159
|
+
.from(attendanceDays)
|
|
160
|
+
.where(and(eq(attendanceDays.workspaceId, workspaceId), inArray(attendanceDays.personId, ids), gte(attendanceDays.businessDate, from), lte(attendanceDays.businessDate, to)))
|
|
161
|
+
.groupBy(attendanceDays.personId);
|
|
162
|
+
for (const r of rows)
|
|
163
|
+
hoursBy.set(r.personId, {
|
|
164
|
+
workedMinutes: Number(r.worked),
|
|
165
|
+
scheduledMinutes: Number(r.scheduled),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
let credited = 0;
|
|
169
|
+
for (const person of staff) {
|
|
170
|
+
if (!mine.has(person.id))
|
|
171
|
+
continue;
|
|
172
|
+
const policy = resolved.get(person.id);
|
|
173
|
+
if (!policy?.config || !person.hiredOn)
|
|
174
|
+
continue;
|
|
175
|
+
const config = policy.config;
|
|
176
|
+
const type = typeByKey.get(config.leaveTypeKey);
|
|
177
|
+
if (!type || already.has(`${person.id}:${type.id}`))
|
|
178
|
+
continue;
|
|
179
|
+
const employment = employmentBy.get(person.id);
|
|
180
|
+
const result = accrueForPeriod({
|
|
181
|
+
policy: {
|
|
182
|
+
frequency: config.frequency,
|
|
183
|
+
daysPerYear: config.daysPerYear,
|
|
184
|
+
minutesPerDay: config.minutesPerDay,
|
|
185
|
+
seniorityTiers: config.seniorityTiers,
|
|
186
|
+
waitingPeriodMonths: config.waitingPeriodMonths,
|
|
187
|
+
roundToMinutes: config.roundToMinutes,
|
|
188
|
+
},
|
|
189
|
+
period: { from, to },
|
|
190
|
+
hiredOn: person.hiredOn,
|
|
191
|
+
terminatedOn: person.terminatedOn,
|
|
192
|
+
fte: employment ? Number.parseFloat(employment.fte ?? '1') : 1,
|
|
193
|
+
...hoursBy.get(person.id),
|
|
194
|
+
});
|
|
195
|
+
if (result.minutes <= 0)
|
|
196
|
+
continue;
|
|
197
|
+
const year = Number(to.slice(0, 4));
|
|
198
|
+
await ledger.lockAndRead(tx, workspaceId, person.id, type.id, year);
|
|
199
|
+
await ledger.append(tx, workspaceId, {
|
|
200
|
+
personId: person.id,
|
|
201
|
+
leaveTypeId: type.id,
|
|
202
|
+
kind: 'accrual',
|
|
203
|
+
amountMinutes: result.minutes,
|
|
204
|
+
effectiveOn: to,
|
|
205
|
+
periodYear: year,
|
|
206
|
+
reason: result.reason,
|
|
207
|
+
});
|
|
208
|
+
credited++;
|
|
209
|
+
}
|
|
210
|
+
if (credited)
|
|
211
|
+
kernel.log.info({ module: 'hr', workspaceId, credited, from, to, offices: officeIds.size }, 'leave accrued');
|
|
140
212
|
}
|
|
141
|
-
if (credited)
|
|
142
|
-
kernel.log.info({ module: 'hr', workspaceId, credited, from, to }, 'leave accrued');
|
|
143
213
|
});
|
|
144
214
|
},
|
|
145
215
|
},
|
|
146
216
|
{
|
|
147
217
|
/**
|
|
148
|
-
* Carry-forward and expiry, on
|
|
218
|
+
* Carry-forward and expiry, on each office's own turn of the year.
|
|
219
|
+
*
|
|
220
|
+
* Two things, on the same hourly tick, because both are a date in an office's zone:
|
|
149
221
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
222
|
+
* 1. **The carry**, when an office reaches 2 January — the 2nd so a late December accrual has
|
|
223
|
+
* already landed. It writes **both halves**: what carried and what lapsed, as separate
|
|
224
|
+
* ledger entries. A balance that silently shrinks at midnight on 1 January is the most
|
|
225
|
+
* disputed number in any leave system, and "you had 9 days, 5 carried, 4 expired under the
|
|
226
|
+
* cap" is a sentence somebody can check.
|
|
227
|
+
* 2. **The lapse**, on every other day of the year, for carried leave that has reached the
|
|
228
|
+
* date it expires on. The cap and the deadline are two different rules and only the first
|
|
229
|
+
* of them was ever written: a policy saying "three months to use it" took nothing away in
|
|
230
|
+
* April, so the deadline was a setting an admin could save and nothing would obey.
|
|
231
|
+
*
|
|
232
|
+
* The day length the cap converts at comes from the **accrual** policy, which is the only
|
|
233
|
+
* place a working day is stated — `CarryForwardConfig` gives a cap in days and nothing to
|
|
234
|
+
* multiply it by. Eight hours was assumed here for months, so on a seven-and-a-half-hour day
|
|
235
|
+
* a five-day cap read as 2400 minutes against 2250 accrued and never bit at all.
|
|
154
236
|
*/
|
|
155
237
|
name: 'carry-forward',
|
|
156
|
-
cron: '0
|
|
238
|
+
cron: '0 * * * *',
|
|
157
239
|
handler: async (_input, { kernel }) => {
|
|
158
240
|
const resolve = new ResolveService();
|
|
159
241
|
const policySvc = new PolicyService(resolve);
|
|
160
242
|
const ledger = new LedgerService();
|
|
161
|
-
const
|
|
162
|
-
const lastYear = thisYear - 1;
|
|
243
|
+
const at = new Date();
|
|
163
244
|
for (const workspaceId of await activeWorkspaces(kernel))
|
|
164
245
|
await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
246
|
+
const days = await officeDays(tx, workspaceId, at);
|
|
247
|
+
if (!days.length)
|
|
248
|
+
return;
|
|
165
249
|
const staff = await tx
|
|
166
250
|
.select({ id: people.id })
|
|
167
251
|
.from(people)
|
|
168
252
|
.where(and(eq(people.workspaceId, workspaceId), inArray(people.status, ['active', 'on_leave'])));
|
|
169
253
|
if (!staff.length)
|
|
170
254
|
return;
|
|
171
|
-
const
|
|
172
|
-
const resolved = await policySvc.forPeople(tx, workspaceId, ids, 'carry_forward', `${lastYear}-12-31`);
|
|
255
|
+
const everyone = staff.map((p) => p.id);
|
|
173
256
|
const types = await tx.select().from(leaveTypes).where(eq(leaveTypes.workspaceId, workspaceId));
|
|
174
257
|
const typeByKey = new Map(types.map((t) => [t.key, t]));
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (!
|
|
258
|
+
// ---- the turn of the year, for the offices that have reached it
|
|
259
|
+
const turning = new Map();
|
|
260
|
+
for (const office of days) {
|
|
261
|
+
if (!office.today.endsWith('-01-02'))
|
|
179
262
|
continue;
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
263
|
+
const year = Number(office.today.slice(0, 4));
|
|
264
|
+
const group = turning.get(year) ?? new Set();
|
|
265
|
+
group.add(office.id);
|
|
266
|
+
turning.set(year, group);
|
|
267
|
+
}
|
|
268
|
+
for (const [year, officeIds] of turning) {
|
|
269
|
+
const lastYear = year - 1;
|
|
270
|
+
const closesOn = `${lastYear}-12-31`;
|
|
271
|
+
const opensOn = `${year}-01-01`;
|
|
272
|
+
const ids = await inOffices(resolve, tx, workspaceId, everyone, closesOn, officeIds);
|
|
273
|
+
if (!ids.length)
|
|
183
274
|
continue;
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
|
|
275
|
+
const carryPolicies = await policySvc.forPeople(tx, workspaceId, ids, 'carry_forward', closesOn);
|
|
276
|
+
const accrualPolicies = await policySvc.forPeople(tx, workspaceId, ids, 'accrual', closesOn);
|
|
277
|
+
let moved = 0;
|
|
278
|
+
for (const personId of ids) {
|
|
279
|
+
const policy = carryPolicies.get(personId);
|
|
280
|
+
if (!policy?.config)
|
|
281
|
+
continue;
|
|
282
|
+
const config = policy.config;
|
|
283
|
+
const type = typeByKey.get(config.leaveTypeKey);
|
|
284
|
+
if (!type)
|
|
285
|
+
continue;
|
|
286
|
+
// A cap in days needs a day, and only the accrual policy states one. Without it the
|
|
287
|
+
// honest move is to touch nothing and say so: guessing eight hours is how a cap
|
|
288
|
+
// silently stops biting on a 7.5-hour week, and inventing one here would make the
|
|
289
|
+
// carried figure disagree with every accrual that produced it.
|
|
290
|
+
const accrual = accrualPolicies.get(personId)?.config;
|
|
291
|
+
const minutesPerDay = accrual?.minutesPerDay;
|
|
292
|
+
if (!minutesPerDay) {
|
|
293
|
+
kernel.log.warn({ module: 'hr', workspaceId, personId, policyId: policy.policyId }, 'carry-forward skipped: no accrual policy states the length of a working day');
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const balance = await ledger.lockAndRead(tx, workspaceId, personId, type.id, lastYear);
|
|
297
|
+
if (balance <= 0)
|
|
298
|
+
continue;
|
|
299
|
+
const { carriedMinutes, expiredMinutes, expiresOn } = carryForward(balance, {
|
|
300
|
+
maxDays: config.maxDays,
|
|
301
|
+
minutesPerDay,
|
|
302
|
+
expiresAfterMonths: config.expiresAfterMonths ?? null,
|
|
303
|
+
}, opensOn);
|
|
304
|
+
// The old year is closed out in full, then what survives opens the new one. Two
|
|
305
|
+
// entries rather than a transfer, so each year's ledger sums to what that year held.
|
|
306
|
+
if (expiredMinutes > 0)
|
|
307
|
+
await ledger.append(tx, workspaceId, {
|
|
308
|
+
personId,
|
|
309
|
+
leaveTypeId: type.id,
|
|
310
|
+
kind: 'expiry',
|
|
311
|
+
amountMinutes: -expiredMinutes,
|
|
312
|
+
effectiveOn: closesOn,
|
|
313
|
+
periodYear: lastYear,
|
|
314
|
+
reason: `Above the ${config.maxDays} day carry-forward cap`,
|
|
315
|
+
});
|
|
316
|
+
if (carriedMinutes > 0) {
|
|
317
|
+
await ledger.append(tx, workspaceId, {
|
|
318
|
+
personId,
|
|
319
|
+
leaveTypeId: type.id,
|
|
320
|
+
kind: 'carry_out',
|
|
321
|
+
amountMinutes: -carriedMinutes,
|
|
322
|
+
effectiveOn: closesOn,
|
|
323
|
+
periodYear: lastYear,
|
|
324
|
+
reason: `Carried into ${year}`,
|
|
325
|
+
});
|
|
326
|
+
await ledger.lockAndRead(tx, workspaceId, personId, type.id, year);
|
|
327
|
+
await ledger.append(tx, workspaceId, {
|
|
328
|
+
personId,
|
|
329
|
+
leaveTypeId: type.id,
|
|
330
|
+
kind: 'carry_in',
|
|
331
|
+
amountMinutes: carriedMinutes,
|
|
332
|
+
effectiveOn: opensOn,
|
|
333
|
+
periodYear: year,
|
|
334
|
+
// The deadline is on the entry a person reads, not only in a policy screen they
|
|
335
|
+
// never open. The sweep below recomputes it rather than parsing it back.
|
|
336
|
+
reason: expiresOn
|
|
337
|
+
? `Carried from ${lastYear} · use by ${dayBefore(expiresOn)}`
|
|
338
|
+
: `Carried from ${lastYear}`,
|
|
339
|
+
});
|
|
340
|
+
moved++;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (moved)
|
|
344
|
+
kernel.log.info({ module: 'hr', workspaceId, year, moved, offices: officeIds.size }, 'leave carried forward');
|
|
345
|
+
}
|
|
346
|
+
// ---- carried leave that has reached its deadline, in each office's own calendar
|
|
347
|
+
const local = new Map();
|
|
348
|
+
for (const office of days) {
|
|
349
|
+
const group = local.get(office.today) ?? new Set();
|
|
350
|
+
group.add(office.id);
|
|
351
|
+
local.set(office.today, group);
|
|
352
|
+
}
|
|
353
|
+
for (const [today, officeIds] of local) {
|
|
354
|
+
const ids = await inOffices(resolve, tx, workspaceId, everyone, today, officeIds);
|
|
355
|
+
if (!ids.length)
|
|
187
356
|
continue;
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
357
|
+
const lapsed = await lapseCarriedLeave({
|
|
358
|
+
tx,
|
|
359
|
+
workspaceId,
|
|
360
|
+
ledger,
|
|
361
|
+
policySvc,
|
|
362
|
+
today,
|
|
363
|
+
personIds: ids,
|
|
364
|
+
typeByKey,
|
|
191
365
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (expiredMinutes > 0)
|
|
195
|
-
await ledger.append(tx, workspaceId, {
|
|
196
|
-
personId: person.id,
|
|
197
|
-
leaveTypeId: type.id,
|
|
198
|
-
kind: 'expiry',
|
|
199
|
-
amountMinutes: -expiredMinutes,
|
|
200
|
-
effectiveOn: `${lastYear}-12-31`,
|
|
201
|
-
periodYear: lastYear,
|
|
202
|
-
reason: `Above the ${config.maxDays} day carry-forward cap`,
|
|
203
|
-
});
|
|
204
|
-
if (carriedMinutes > 0) {
|
|
205
|
-
await ledger.append(tx, workspaceId, {
|
|
206
|
-
personId: person.id,
|
|
207
|
-
leaveTypeId: type.id,
|
|
208
|
-
kind: 'carry_out',
|
|
209
|
-
amountMinutes: -carriedMinutes,
|
|
210
|
-
effectiveOn: `${lastYear}-12-31`,
|
|
211
|
-
periodYear: lastYear,
|
|
212
|
-
reason: `Carried into ${thisYear}`,
|
|
213
|
-
});
|
|
214
|
-
await ledger.lockAndRead(tx, workspaceId, person.id, type.id, thisYear);
|
|
215
|
-
await ledger.append(tx, workspaceId, {
|
|
216
|
-
personId: person.id,
|
|
217
|
-
leaveTypeId: type.id,
|
|
218
|
-
kind: 'carry_in',
|
|
219
|
-
amountMinutes: carriedMinutes,
|
|
220
|
-
effectiveOn: `${thisYear}-01-01`,
|
|
221
|
-
periodYear: thisYear,
|
|
222
|
-
reason: `Carried from ${lastYear}`,
|
|
223
|
-
});
|
|
224
|
-
moved++;
|
|
225
|
-
}
|
|
366
|
+
if (lapsed)
|
|
367
|
+
kernel.log.info({ module: 'hr', workspaceId, today, lapsed }, 'carried leave expired');
|
|
226
368
|
}
|
|
227
|
-
if (moved)
|
|
228
|
-
kernel.log.info({ module: 'hr', workspaceId, moved }, 'leave carried forward');
|
|
229
369
|
});
|
|
230
370
|
},
|
|
231
371
|
},
|
|
@@ -233,8 +373,15 @@ export function hrJobs() {
|
|
|
233
373
|
/**
|
|
234
374
|
* Close shifts somebody forgot to clock out of.
|
|
235
375
|
*
|
|
236
|
-
* Hourly, and
|
|
237
|
-
*
|
|
376
|
+
* Hourly, and the one job here whose boundary is **not** a date in an office's calendar: a
|
|
377
|
+
* shift closes `auto_clock_out_after_minutes` after the punch that opened it, and an elapsed
|
|
378
|
+
* hour is the same hour everywhere. What the office decides is the **zone** — the zone of the
|
|
379
|
+
* office the person worked in **on the day being closed**, not the one they work in today.
|
|
380
|
+
* Somebody who transferred from Istanbul to Amsterdam has their forgotten Istanbul shift
|
|
381
|
+
* closed in Istanbul time.
|
|
382
|
+
*
|
|
383
|
+
* So the ladder is walked once per business date rather than once per shift, which is the
|
|
384
|
+
* batching `ResolveService` asks for and which a call per row quietly gave up.
|
|
238
385
|
*
|
|
239
386
|
* The auto clock-out is written as a punch like any other, with `method: 'manual'` and a note,
|
|
240
387
|
* so the sheet shows that a machine closed the day rather than the person. That distinction is
|
|
@@ -257,6 +404,31 @@ export function hrJobs() {
|
|
|
257
404
|
// Anyone with an `in` and no matching `out`, older than the longest configured window.
|
|
258
405
|
const longest = Math.max(...withAuto.map((s) => s.after ?? 0));
|
|
259
406
|
const cutoff = new Date(Date.now() - longest * 60_000);
|
|
407
|
+
// `at <= cutoff` with no lower bound asks for every `in` punch the instance has ever
|
|
408
|
+
// recorded. Measured on 150,000 punches over five months: a sequential scan of every
|
|
409
|
+
// populated partition, 100,000 rows and 2,462 buffers, with the index in place and
|
|
410
|
+
// unused. With the two lower bounds it is a bitmap index scan of the one partition the
|
|
411
|
+
// lookback covers — 2,000 rows, 1,041 buffers — because `at` is what the index can
|
|
412
|
+
// answer and `business_date` is what prunes the partitions. The date bound is a day
|
|
413
|
+
// earlier than the instant one: a night shift's punches carry the date the shift
|
|
414
|
+
// *started*, which is the day before the morning they were made on.
|
|
415
|
+
//
|
|
416
|
+
// The cost of the bound is real and worth stating: a shift left open for longer than
|
|
417
|
+
// the lookback is never closed automatically. That is close to true already — the
|
|
418
|
+
// window a schedule configures is hours, not days — and three days leaves slack for the
|
|
419
|
+
// job itself having been down.
|
|
420
|
+
const since = new Date(cutoff.getTime() - AUTO_CLOCK_OUT_LOOKBACK_DAYS * 86_400_000);
|
|
421
|
+
const sinceDate = dayBefore(since.toISOString().slice(0, 10));
|
|
422
|
+
// "Still open" is a question about the punches of one person-day, and it used to be
|
|
423
|
+
// answered here — a query per candidate row, before anything was known about the person
|
|
424
|
+
// it belonged to. As an anti-join it costs nothing extra and the loop below only ever
|
|
425
|
+
// sees shifts that really are open.
|
|
426
|
+
//
|
|
427
|
+
// The three predicates above it are the ones `hr_punches_open_idx` is built from and
|
|
428
|
+
// must not move: `voided_by_punch_id is null` is the index's partial predicate because
|
|
429
|
+
// drizzle emits `is null` literally and the planner can prove the implication, while
|
|
430
|
+
// `direction` arrives as a bind parameter, which a predicate's constant cannot match.
|
|
431
|
+
const closing = alias(punches, 'closing');
|
|
260
432
|
const open = await tx
|
|
261
433
|
.select({
|
|
262
434
|
personId: punches.personId,
|
|
@@ -264,46 +436,76 @@ export function hrJobs() {
|
|
|
264
436
|
at: punches.at,
|
|
265
437
|
})
|
|
266
438
|
.from(punches)
|
|
267
|
-
.where(and(eq(punches.workspaceId, workspaceId), eq(punches.direction, 'in'), isNull(punches.voidedByPunchId), lte(punches.at, cutoff)))
|
|
439
|
+
.where(and(eq(punches.workspaceId, workspaceId), eq(punches.direction, 'in'), isNull(punches.voidedByPunchId), lte(punches.at, cutoff), gte(punches.at, since), gte(punches.businessDate, sinceDate), notExists(tx
|
|
440
|
+
.select({ closed: sql `1` })
|
|
441
|
+
.from(closing)
|
|
442
|
+
.where(and(eq(closing.workspaceId, workspaceId), eq(closing.personId, punches.personId), eq(closing.businessDate, punches.businessDate), eq(closing.direction, 'out'), isNull(closing.voidedByPunchId), gt(closing.at, punches.at),
|
|
443
|
+
// Implied by the equality above, and stated anyway: without it nothing
|
|
444
|
+
// bounds the date on this side, and the planner then drives the anti-join
|
|
445
|
+
// from here — reading every populated partition for `out` punches to
|
|
446
|
+
// find the handful that close anything. Measured at 2,949 buffers
|
|
447
|
+
// against 865 with the bounds, on the same rows.
|
|
448
|
+
gte(closing.businessDate, sinceDate), gte(closing.at, since))))));
|
|
449
|
+
// One shift per person-day, not one per `in` punch. Somebody who clocked in twice and
|
|
450
|
+
// never out has one open shift, and closing it twice would write two clock-outs for a
|
|
451
|
+
// day that had one. Deduping here rather than with `distinct on` keeps the statement's
|
|
452
|
+
// plan on the index above instead of on whatever answers the ordering cheapest.
|
|
453
|
+
const shifts = new Map();
|
|
268
454
|
for (const row of open) {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
if (!
|
|
272
|
-
|
|
273
|
-
|
|
455
|
+
const key = `${row.personId}:${row.businessDate}`;
|
|
456
|
+
const seen = shifts.get(key);
|
|
457
|
+
if (!seen || row.at > seen.at)
|
|
458
|
+
shifts.set(key, row);
|
|
459
|
+
}
|
|
460
|
+
if (!shifts.size)
|
|
461
|
+
return;
|
|
462
|
+
// One ladder walk per business date rather than one per shift. The resolution is what
|
|
463
|
+
// says which office — and so which zone — the day belongs to, and it is asked **as of
|
|
464
|
+
// that day**: a sweep that asks as of today closes a transferred employee's old shift
|
|
465
|
+
// in their new office's time.
|
|
466
|
+
const perDate = new Map();
|
|
467
|
+
for (const row of shifts.values())
|
|
468
|
+
perDate.set(row.businessDate, [...(perDate.get(row.businessDate) ?? []), row.personId]);
|
|
469
|
+
const zones = new Map();
|
|
470
|
+
for (const [businessDate, personIds] of perDate)
|
|
471
|
+
for (const [personId, r] of await resolve.forPeople(tx, workspaceId, personIds, businessDate))
|
|
472
|
+
zones.set(`${personId}:${businessDate}`, r.timezone);
|
|
473
|
+
for (const row of shifts.values()) {
|
|
274
474
|
const schedule = await attendance.scheduleFor(tx, workspaceId, row.personId, row.businessDate);
|
|
275
475
|
if (!schedule.autoClockOutAfterMinutes)
|
|
276
476
|
continue;
|
|
277
477
|
if (Date.now() - row.at.getTime() < schedule.autoClockOutAfterMinutes * 60_000)
|
|
278
478
|
continue;
|
|
479
|
+
const timezone = zones.get(`${row.personId}:${row.businessDate}`) ?? 'UTC';
|
|
279
480
|
await tx.insert(punches).values({
|
|
280
481
|
workspaceId,
|
|
281
482
|
personId: row.personId,
|
|
282
483
|
direction: 'out',
|
|
283
484
|
at: new Date(row.at.getTime() + schedule.autoClockOutAfterMinutes * 60_000),
|
|
284
485
|
businessDate: row.businessDate,
|
|
285
|
-
timezone
|
|
486
|
+
timezone,
|
|
286
487
|
method: 'manual',
|
|
287
488
|
trust: 'trusted',
|
|
288
489
|
note: 'Closed automatically: no clock-out recorded',
|
|
289
490
|
});
|
|
290
|
-
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate,
|
|
291
|
-
kernel.log.info({ module: 'hr', personId: row.personId, businessDate: row.businessDate }, 'auto clock-out');
|
|
491
|
+
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
492
|
+
kernel.log.info({ module: 'hr', personId: row.personId, businessDate: row.businessDate, timezone }, 'auto clock-out');
|
|
292
493
|
}
|
|
293
494
|
});
|
|
294
495
|
},
|
|
295
496
|
},
|
|
296
497
|
{
|
|
297
498
|
/**
|
|
298
|
-
* Rebuild recent
|
|
499
|
+
* Rebuild every recent day a period does not close.
|
|
299
500
|
*
|
|
300
501
|
* Punches recompute their own day inline, so this exists for what that path cannot see: a
|
|
301
502
|
* calendar edited after the fact, a schedule changed retroactively, an enqueue that never
|
|
302
503
|
* ran. Anything it finds and changes is a bug worth knowing about rather than routine
|
|
303
504
|
* maintenance — which is why it logs a count instead of running silently.
|
|
304
505
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
506
|
+
* A day a period really does close is still never touched — a filed payroll must not move
|
|
507
|
+
* underneath itself — but that is `recomputeDay`'s answer to give, not this query's. `touched`
|
|
508
|
+
* counts the days it actually rebuilt, so a window full of a closed month still logs nothing.
|
|
307
509
|
*/
|
|
308
510
|
name: 'reconcile-days',
|
|
309
511
|
cron: '30 2 * * *',
|
|
@@ -321,11 +523,18 @@ export function hrJobs() {
|
|
|
321
523
|
businessDate: attendanceDays.businessDate,
|
|
322
524
|
})
|
|
323
525
|
.from(attendanceDays)
|
|
324
|
-
.where(and(eq(attendanceDays.workspaceId, workspaceId),
|
|
526
|
+
.where(and(eq(attendanceDays.workspaceId, workspaceId), sql `${attendanceDays.businessDate} >= ${since}`));
|
|
325
527
|
let touched = 0;
|
|
326
528
|
for (const day of days) {
|
|
327
529
|
const resolution = await resolve.forPerson(tx, workspaceId, day.personId, day.businessDate);
|
|
328
530
|
const schedule = await attendance.scheduleFor(tx, workspaceId, day.personId, day.businessDate);
|
|
531
|
+
// Every row in the window is offered, whatever its flag says. `locked` is a cache of
|
|
532
|
+
// an answer only the period holds, and `recomputeDay` is the one place entitled to
|
|
533
|
+
// read it — in both directions, since a flag repaired only upwards is a trapdoor
|
|
534
|
+
// rather than a cache. Selecting `locked = false` here excluded exactly the rows the
|
|
535
|
+
// downward repair exists for: a day stamped by a lock that has since been reopened,
|
|
536
|
+
// or by somebody's employment being corrected underneath one, could never be visited
|
|
537
|
+
// by anything a running instance does. A repair nothing reaches is not a repair.
|
|
329
538
|
const r = await attendance.recomputeDay(tx, workspaceId, day.personId, day.businessDate, resolution.timezone, schedule);
|
|
330
539
|
if (!r.locked)
|
|
331
540
|
touched++;
|
|
@@ -347,5 +556,141 @@ async function activeWorkspaces(kernel) {
|
|
|
347
556
|
const { rows } = await kernel.database.pool.query(`select distinct workspace_id from mod_hr.offices where archived_at is null`);
|
|
348
557
|
return rows.map((r) => r.workspace_id);
|
|
349
558
|
}
|
|
350
|
-
|
|
559
|
+
/**
|
|
560
|
+
* Every live office of a workspace, with the date it is on at `at`.
|
|
561
|
+
*
|
|
562
|
+
* The whole fan-out is this one function: a job asks which offices have crossed the boundary it
|
|
563
|
+
* cares about instead of asking a cron expression, which only ever knows UTC.
|
|
564
|
+
*/
|
|
565
|
+
async function officeDays(tx, workspaceId, at) {
|
|
566
|
+
const rows = await tx
|
|
567
|
+
.select({ id: offices.id, name: offices.name, timezone: offices.timezone })
|
|
568
|
+
.from(offices)
|
|
569
|
+
.where(and(eq(offices.workspaceId, workspaceId), isNull(offices.archivedAt)));
|
|
570
|
+
return rows.map((o) => ({ ...o, today: todayIn(o.timezone, at) }));
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Which of these people a set of offices decides for, on a date.
|
|
574
|
+
*
|
|
575
|
+
* Only the primary office votes, and `ResolveService` is the only implementation of that — so the
|
|
576
|
+
* question is asked there rather than by joining `office_assignments` here and growing a second,
|
|
577
|
+
* subtly different ladder. Nobody falls out of every group: a workspace always has a default
|
|
578
|
+
* office, and it answers for anyone without an assignment of their own.
|
|
579
|
+
*/
|
|
580
|
+
async function inOffices(resolve, tx, workspaceId, personIds, on, officeIds) {
|
|
581
|
+
const resolutions = await resolve.forPeople(tx, workspaceId, personIds, on);
|
|
582
|
+
return personIds.filter((id) => {
|
|
583
|
+
const officeId = resolutions.get(id)?.primaryOfficeId;
|
|
584
|
+
return !!officeId && officeIds.has(officeId);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Take away carried leave that has passed its deadline, and say so on the record.
|
|
589
|
+
*
|
|
590
|
+
* The deadline is **recomputed** from the carry-forward policy in force on the 1st of January the
|
|
591
|
+
* leave was carried into, rather than parsed back out of the entry that carried it: a policy is
|
|
592
|
+
* effective-dated, so the same question asked of the same date gives the same answer for ever, and
|
|
593
|
+
* a date encoded in a `reason` string is a column nobody declared.
|
|
594
|
+
*
|
|
595
|
+
* How much is left of the carry is a FIFO question — carried days are spent before the new year's
|
|
596
|
+
* own accrual, so what lapses is the carry minus everything spent since, and never more than the
|
|
597
|
+
* balance actually standing. That also makes the sweep idempotent without a marker: the entry it
|
|
598
|
+
* writes is itself spending, so the second run finds nothing left to take.
|
|
599
|
+
*/
|
|
600
|
+
async function lapseCarriedLeave(args) {
|
|
601
|
+
const { tx, workspaceId, ledger, policySvc, today, personIds, typeByKey } = args;
|
|
602
|
+
const thisYear = Number(today.slice(0, 4));
|
|
603
|
+
// Only the years that actually carried anything, and only as far back as a deadline can reach:
|
|
604
|
+
// `expiresAfterMonths` is capped at 24 by the contract.
|
|
605
|
+
const carriedYears = await tx
|
|
606
|
+
.selectDistinct({ periodYear: leaveLedger.periodYear })
|
|
607
|
+
.from(leaveLedger)
|
|
608
|
+
.where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.kind, 'carry_in'), inArray(leaveLedger.personId, personIds), gte(leaveLedger.periodYear, thisYear - 2), lte(leaveLedger.periodYear, thisYear)));
|
|
609
|
+
if (!carriedYears.length)
|
|
610
|
+
return 0;
|
|
611
|
+
let lapsed = 0;
|
|
612
|
+
for (const { periodYear } of carriedYears) {
|
|
613
|
+
const yearStart = `${periodYear}-01-01`;
|
|
614
|
+
const policies = await policySvc.forPeople(tx, workspaceId, personIds, 'carry_forward', yearStart);
|
|
615
|
+
const due = personIds.filter((personId) => {
|
|
616
|
+
const config = policies.get(personId)?.config;
|
|
617
|
+
if (!config)
|
|
618
|
+
return false;
|
|
619
|
+
const expiresOn = carryExpiryDate(yearStart, config.expiresAfterMonths ?? null);
|
|
620
|
+
return carryHasLapsed(today, expiresOn);
|
|
621
|
+
});
|
|
622
|
+
if (!due.length)
|
|
623
|
+
continue;
|
|
624
|
+
const sums = await tx
|
|
625
|
+
.select({
|
|
626
|
+
personId: leaveLedger.personId,
|
|
627
|
+
leaveTypeId: leaveLedger.leaveTypeId,
|
|
628
|
+
kind: leaveLedger.kind,
|
|
629
|
+
total: sql `sum(${leaveLedger.amountMinutes})::int`,
|
|
630
|
+
})
|
|
631
|
+
.from(leaveLedger)
|
|
632
|
+
.where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.periodYear, periodYear), inArray(leaveLedger.personId, due)))
|
|
633
|
+
.groupBy(leaveLedger.personId, leaveLedger.leaveTypeId, leaveLedger.kind);
|
|
634
|
+
const tally = new Map();
|
|
635
|
+
for (const row of sums) {
|
|
636
|
+
const key = `${row.personId}:${row.leaveTypeId}`;
|
|
637
|
+
const t = tally.get(key) ?? { carriedIn: 0, balance: 0, spent: 0 };
|
|
638
|
+
const total = Number(row.total);
|
|
639
|
+
t.balance += total;
|
|
640
|
+
if (row.kind === 'carry_in')
|
|
641
|
+
t.carriedIn += total;
|
|
642
|
+
if (total < 0)
|
|
643
|
+
t.spent += -total;
|
|
644
|
+
tally.set(key, t);
|
|
645
|
+
}
|
|
646
|
+
for (const personId of due) {
|
|
647
|
+
const config = policies.get(personId)?.config;
|
|
648
|
+
const type = typeByKey.get(config.leaveTypeKey);
|
|
649
|
+
if (!type)
|
|
650
|
+
continue;
|
|
651
|
+
const expiresOn = carryExpiryDate(yearStart, config.expiresAfterMonths ?? null);
|
|
652
|
+
if (!expiresOn)
|
|
653
|
+
continue;
|
|
654
|
+
const t = tally.get(`${personId}:${type.id}`);
|
|
655
|
+
if (!t?.carriedIn)
|
|
656
|
+
continue;
|
|
657
|
+
const remaining = Math.min(Math.max(0, t.carriedIn - t.spent), t.balance);
|
|
658
|
+
if (remaining <= 0)
|
|
659
|
+
continue;
|
|
660
|
+
await ledger.lockAndRead(tx, workspaceId, personId, type.id, periodYear);
|
|
661
|
+
await ledger.append(tx, workspaceId, {
|
|
662
|
+
personId,
|
|
663
|
+
leaveTypeId: type.id,
|
|
664
|
+
kind: 'expiry',
|
|
665
|
+
amountMinutes: -remaining,
|
|
666
|
+
effectiveOn: expiresOn,
|
|
667
|
+
periodYear,
|
|
668
|
+
reason: `Carried leave not used by ${dayBefore(expiresOn)}`,
|
|
669
|
+
});
|
|
670
|
+
lapsed++;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return lapsed;
|
|
674
|
+
}
|
|
675
|
+
/** The month before the one a `YYYY-MM-01` names, as its own first and last day. */
|
|
676
|
+
function monthBefore(firstOfMonth) {
|
|
677
|
+
const [y, m] = firstOfMonth.split('-').map(Number);
|
|
678
|
+
const year = m === 1 ? y - 1 : y;
|
|
679
|
+
const month = m === 1 ? 12 : m - 1;
|
|
680
|
+
const mm = String(month).padStart(2, '0');
|
|
681
|
+
const last = String(daysInMonth(year, month)).padStart(2, '0');
|
|
682
|
+
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${last}` };
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* The day before a `YYYY-MM-DD`.
|
|
686
|
+
*
|
|
687
|
+
* An expiry date is the first day the leave is *gone*, which is not the date to show somebody or to
|
|
688
|
+
* bound a sweep with — "use by 31 March" and "expires 1 April" are the same rule and only one of
|
|
689
|
+
* them reads as an instruction.
|
|
690
|
+
*/
|
|
691
|
+
function dayBefore(date) {
|
|
692
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
693
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
694
|
+
return d.toISOString().slice(0, 10);
|
|
695
|
+
}
|
|
351
696
|
//# sourceMappingURL=jobs.js.map
|