@kernhq/module-hr 0.9.3 → 0.10.1
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/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.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 +9 -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 +458 -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 +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- 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 +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- 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 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- 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 +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- 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 +43 -0
- 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/approvals.ts +13 -1
- 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,271 @@ 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
|
-
credited
|
|
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
|
+
let credited = 0;
|
|
139
|
+
for (const person of staff) {
|
|
140
|
+
if (!mine.has(person.id))
|
|
141
|
+
continue;
|
|
142
|
+
const policy = resolved.get(person.id);
|
|
143
|
+
if (!policy?.config || !person.hiredOn)
|
|
144
|
+
continue;
|
|
145
|
+
const config = policy.config;
|
|
146
|
+
const type = typeByKey.get(config.leaveTypeKey);
|
|
147
|
+
if (!type || already.has(`${person.id}:${type.id}`))
|
|
148
|
+
continue;
|
|
149
|
+
const employment = employmentBy.get(person.id);
|
|
150
|
+
const result = accrueForPeriod({
|
|
151
|
+
policy: {
|
|
152
|
+
frequency: config.frequency,
|
|
153
|
+
daysPerYear: config.daysPerYear,
|
|
154
|
+
minutesPerDay: config.minutesPerDay,
|
|
155
|
+
seniorityTiers: config.seniorityTiers,
|
|
156
|
+
waitingPeriodMonths: config.waitingPeriodMonths,
|
|
157
|
+
roundToMinutes: config.roundToMinutes,
|
|
158
|
+
},
|
|
159
|
+
period: { from, to },
|
|
160
|
+
hiredOn: person.hiredOn,
|
|
161
|
+
terminatedOn: person.terminatedOn,
|
|
162
|
+
fte: employment ? Number.parseFloat(employment.fte ?? '1') : 1,
|
|
163
|
+
});
|
|
164
|
+
if (result.minutes <= 0)
|
|
165
|
+
continue;
|
|
166
|
+
const year = Number(to.slice(0, 4));
|
|
167
|
+
await ledger.lockAndRead(tx, workspaceId, person.id, type.id, year);
|
|
168
|
+
await ledger.append(tx, workspaceId, {
|
|
169
|
+
personId: person.id,
|
|
170
|
+
leaveTypeId: type.id,
|
|
171
|
+
kind: 'accrual',
|
|
172
|
+
amountMinutes: result.minutes,
|
|
173
|
+
effectiveOn: to,
|
|
174
|
+
periodYear: year,
|
|
175
|
+
reason: result.reason,
|
|
176
|
+
});
|
|
177
|
+
credited++;
|
|
178
|
+
}
|
|
179
|
+
if (credited)
|
|
180
|
+
kernel.log.info({ module: 'hr', workspaceId, credited, from, to, offices: officeIds.size }, 'leave accrued');
|
|
140
181
|
}
|
|
141
|
-
if (credited)
|
|
142
|
-
kernel.log.info({ module: 'hr', workspaceId, credited, from, to }, 'leave accrued');
|
|
143
182
|
});
|
|
144
183
|
},
|
|
145
184
|
},
|
|
146
185
|
{
|
|
147
186
|
/**
|
|
148
|
-
* Carry-forward and expiry, on
|
|
187
|
+
* Carry-forward and expiry, on each office's own turn of the year.
|
|
149
188
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
189
|
+
* Two things, on the same hourly tick, because both are a date in an office's zone:
|
|
190
|
+
*
|
|
191
|
+
* 1. **The carry**, when an office reaches 2 January — the 2nd so a late December accrual has
|
|
192
|
+
* already landed. It writes **both halves**: what carried and what lapsed, as separate
|
|
193
|
+
* ledger entries. A balance that silently shrinks at midnight on 1 January is the most
|
|
194
|
+
* disputed number in any leave system, and "you had 9 days, 5 carried, 4 expired under the
|
|
195
|
+
* cap" is a sentence somebody can check.
|
|
196
|
+
* 2. **The lapse**, on every other day of the year, for carried leave that has reached the
|
|
197
|
+
* date it expires on. The cap and the deadline are two different rules and only the first
|
|
198
|
+
* of them was ever written: a policy saying "three months to use it" took nothing away in
|
|
199
|
+
* April, so the deadline was a setting an admin could save and nothing would obey.
|
|
200
|
+
*
|
|
201
|
+
* The day length the cap converts at comes from the **accrual** policy, which is the only
|
|
202
|
+
* place a working day is stated — `CarryForwardConfig` gives a cap in days and nothing to
|
|
203
|
+
* multiply it by. Eight hours was assumed here for months, so on a seven-and-a-half-hour day
|
|
204
|
+
* a five-day cap read as 2400 minutes against 2250 accrued and never bit at all.
|
|
154
205
|
*/
|
|
155
206
|
name: 'carry-forward',
|
|
156
|
-
cron: '0
|
|
207
|
+
cron: '0 * * * *',
|
|
157
208
|
handler: async (_input, { kernel }) => {
|
|
158
209
|
const resolve = new ResolveService();
|
|
159
210
|
const policySvc = new PolicyService(resolve);
|
|
160
211
|
const ledger = new LedgerService();
|
|
161
|
-
const
|
|
162
|
-
const lastYear = thisYear - 1;
|
|
212
|
+
const at = new Date();
|
|
163
213
|
for (const workspaceId of await activeWorkspaces(kernel))
|
|
164
214
|
await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
215
|
+
const days = await officeDays(tx, workspaceId, at);
|
|
216
|
+
if (!days.length)
|
|
217
|
+
return;
|
|
165
218
|
const staff = await tx
|
|
166
219
|
.select({ id: people.id })
|
|
167
220
|
.from(people)
|
|
168
221
|
.where(and(eq(people.workspaceId, workspaceId), inArray(people.status, ['active', 'on_leave'])));
|
|
169
222
|
if (!staff.length)
|
|
170
223
|
return;
|
|
171
|
-
const
|
|
172
|
-
const resolved = await policySvc.forPeople(tx, workspaceId, ids, 'carry_forward', `${lastYear}-12-31`);
|
|
224
|
+
const everyone = staff.map((p) => p.id);
|
|
173
225
|
const types = await tx.select().from(leaveTypes).where(eq(leaveTypes.workspaceId, workspaceId));
|
|
174
226
|
const typeByKey = new Map(types.map((t) => [t.key, t]));
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (!
|
|
227
|
+
// ---- the turn of the year, for the offices that have reached it
|
|
228
|
+
const turning = new Map();
|
|
229
|
+
for (const office of days) {
|
|
230
|
+
if (!office.today.endsWith('-01-02'))
|
|
179
231
|
continue;
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
232
|
+
const year = Number(office.today.slice(0, 4));
|
|
233
|
+
const group = turning.get(year) ?? new Set();
|
|
234
|
+
group.add(office.id);
|
|
235
|
+
turning.set(year, group);
|
|
236
|
+
}
|
|
237
|
+
for (const [year, officeIds] of turning) {
|
|
238
|
+
const lastYear = year - 1;
|
|
239
|
+
const closesOn = `${lastYear}-12-31`;
|
|
240
|
+
const opensOn = `${year}-01-01`;
|
|
241
|
+
const ids = await inOffices(resolve, tx, workspaceId, everyone, closesOn, officeIds);
|
|
242
|
+
if (!ids.length)
|
|
183
243
|
continue;
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
|
|
244
|
+
const carryPolicies = await policySvc.forPeople(tx, workspaceId, ids, 'carry_forward', closesOn);
|
|
245
|
+
const accrualPolicies = await policySvc.forPeople(tx, workspaceId, ids, 'accrual', closesOn);
|
|
246
|
+
let moved = 0;
|
|
247
|
+
for (const personId of ids) {
|
|
248
|
+
const policy = carryPolicies.get(personId);
|
|
249
|
+
if (!policy?.config)
|
|
250
|
+
continue;
|
|
251
|
+
const config = policy.config;
|
|
252
|
+
const type = typeByKey.get(config.leaveTypeKey);
|
|
253
|
+
if (!type)
|
|
254
|
+
continue;
|
|
255
|
+
// A cap in days needs a day, and only the accrual policy states one. Without it the
|
|
256
|
+
// honest move is to touch nothing and say so: guessing eight hours is how a cap
|
|
257
|
+
// silently stops biting on a 7.5-hour week, and inventing one here would make the
|
|
258
|
+
// carried figure disagree with every accrual that produced it.
|
|
259
|
+
const accrual = accrualPolicies.get(personId)?.config;
|
|
260
|
+
const minutesPerDay = accrual?.minutesPerDay;
|
|
261
|
+
if (!minutesPerDay) {
|
|
262
|
+
kernel.log.warn({ module: 'hr', workspaceId, personId, policyId: policy.policyId }, 'carry-forward skipped: no accrual policy states the length of a working day');
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const balance = await ledger.lockAndRead(tx, workspaceId, personId, type.id, lastYear);
|
|
266
|
+
if (balance <= 0)
|
|
267
|
+
continue;
|
|
268
|
+
const { carriedMinutes, expiredMinutes, expiresOn } = carryForward(balance, {
|
|
269
|
+
maxDays: config.maxDays,
|
|
270
|
+
minutesPerDay,
|
|
271
|
+
expiresAfterMonths: config.expiresAfterMonths ?? null,
|
|
272
|
+
}, opensOn);
|
|
273
|
+
// The old year is closed out in full, then what survives opens the new one. Two
|
|
274
|
+
// entries rather than a transfer, so each year's ledger sums to what that year held.
|
|
275
|
+
if (expiredMinutes > 0)
|
|
276
|
+
await ledger.append(tx, workspaceId, {
|
|
277
|
+
personId,
|
|
278
|
+
leaveTypeId: type.id,
|
|
279
|
+
kind: 'expiry',
|
|
280
|
+
amountMinutes: -expiredMinutes,
|
|
281
|
+
effectiveOn: closesOn,
|
|
282
|
+
periodYear: lastYear,
|
|
283
|
+
reason: `Above the ${config.maxDays} day carry-forward cap`,
|
|
284
|
+
});
|
|
285
|
+
if (carriedMinutes > 0) {
|
|
286
|
+
await ledger.append(tx, workspaceId, {
|
|
287
|
+
personId,
|
|
288
|
+
leaveTypeId: type.id,
|
|
289
|
+
kind: 'carry_out',
|
|
290
|
+
amountMinutes: -carriedMinutes,
|
|
291
|
+
effectiveOn: closesOn,
|
|
292
|
+
periodYear: lastYear,
|
|
293
|
+
reason: `Carried into ${year}`,
|
|
294
|
+
});
|
|
295
|
+
await ledger.lockAndRead(tx, workspaceId, personId, type.id, year);
|
|
296
|
+
await ledger.append(tx, workspaceId, {
|
|
297
|
+
personId,
|
|
298
|
+
leaveTypeId: type.id,
|
|
299
|
+
kind: 'carry_in',
|
|
300
|
+
amountMinutes: carriedMinutes,
|
|
301
|
+
effectiveOn: opensOn,
|
|
302
|
+
periodYear: year,
|
|
303
|
+
// The deadline is on the entry a person reads, not only in a policy screen they
|
|
304
|
+
// never open. The sweep below recomputes it rather than parsing it back.
|
|
305
|
+
reason: expiresOn
|
|
306
|
+
? `Carried from ${lastYear} · use by ${dayBefore(expiresOn)}`
|
|
307
|
+
: `Carried from ${lastYear}`,
|
|
308
|
+
});
|
|
309
|
+
moved++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (moved)
|
|
313
|
+
kernel.log.info({ module: 'hr', workspaceId, year, moved, offices: officeIds.size }, 'leave carried forward');
|
|
314
|
+
}
|
|
315
|
+
// ---- carried leave that has reached its deadline, in each office's own calendar
|
|
316
|
+
const local = new Map();
|
|
317
|
+
for (const office of days) {
|
|
318
|
+
const group = local.get(office.today) ?? new Set();
|
|
319
|
+
group.add(office.id);
|
|
320
|
+
local.set(office.today, group);
|
|
321
|
+
}
|
|
322
|
+
for (const [today, officeIds] of local) {
|
|
323
|
+
const ids = await inOffices(resolve, tx, workspaceId, everyone, today, officeIds);
|
|
324
|
+
if (!ids.length)
|
|
187
325
|
continue;
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
326
|
+
const lapsed = await lapseCarriedLeave({
|
|
327
|
+
tx,
|
|
328
|
+
workspaceId,
|
|
329
|
+
ledger,
|
|
330
|
+
policySvc,
|
|
331
|
+
today,
|
|
332
|
+
personIds: ids,
|
|
333
|
+
typeByKey,
|
|
191
334
|
});
|
|
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
|
-
}
|
|
335
|
+
if (lapsed)
|
|
336
|
+
kernel.log.info({ module: 'hr', workspaceId, today, lapsed }, 'carried leave expired');
|
|
226
337
|
}
|
|
227
|
-
if (moved)
|
|
228
|
-
kernel.log.info({ module: 'hr', workspaceId, moved }, 'leave carried forward');
|
|
229
338
|
});
|
|
230
339
|
},
|
|
231
340
|
},
|
|
@@ -233,8 +342,15 @@ export function hrJobs() {
|
|
|
233
342
|
/**
|
|
234
343
|
* Close shifts somebody forgot to clock out of.
|
|
235
344
|
*
|
|
236
|
-
* Hourly, and
|
|
237
|
-
*
|
|
345
|
+
* Hourly, and the one job here whose boundary is **not** a date in an office's calendar: a
|
|
346
|
+
* shift closes `auto_clock_out_after_minutes` after the punch that opened it, and an elapsed
|
|
347
|
+
* hour is the same hour everywhere. What the office decides is the **zone** — the zone of the
|
|
348
|
+
* office the person worked in **on the day being closed**, not the one they work in today.
|
|
349
|
+
* Somebody who transferred from Istanbul to Amsterdam has their forgotten Istanbul shift
|
|
350
|
+
* closed in Istanbul time.
|
|
351
|
+
*
|
|
352
|
+
* So the ladder is walked once per business date rather than once per shift, which is the
|
|
353
|
+
* batching `ResolveService` asks for and which a call per row quietly gave up.
|
|
238
354
|
*
|
|
239
355
|
* The auto clock-out is written as a punch like any other, with `method: 'manual'` and a note,
|
|
240
356
|
* so the sheet shows that a machine closed the day rather than the person. That distinction is
|
|
@@ -257,6 +373,31 @@ export function hrJobs() {
|
|
|
257
373
|
// Anyone with an `in` and no matching `out`, older than the longest configured window.
|
|
258
374
|
const longest = Math.max(...withAuto.map((s) => s.after ?? 0));
|
|
259
375
|
const cutoff = new Date(Date.now() - longest * 60_000);
|
|
376
|
+
// `at <= cutoff` with no lower bound asks for every `in` punch the instance has ever
|
|
377
|
+
// recorded. Measured on 150,000 punches over five months: a sequential scan of every
|
|
378
|
+
// populated partition, 100,000 rows and 2,462 buffers, with the index in place and
|
|
379
|
+
// unused. With the two lower bounds it is a bitmap index scan of the one partition the
|
|
380
|
+
// lookback covers — 2,000 rows, 1,041 buffers — because `at` is what the index can
|
|
381
|
+
// answer and `business_date` is what prunes the partitions. The date bound is a day
|
|
382
|
+
// earlier than the instant one: a night shift's punches carry the date the shift
|
|
383
|
+
// *started*, which is the day before the morning they were made on.
|
|
384
|
+
//
|
|
385
|
+
// The cost of the bound is real and worth stating: a shift left open for longer than
|
|
386
|
+
// the lookback is never closed automatically. That is close to true already — the
|
|
387
|
+
// window a schedule configures is hours, not days — and three days leaves slack for the
|
|
388
|
+
// job itself having been down.
|
|
389
|
+
const since = new Date(cutoff.getTime() - AUTO_CLOCK_OUT_LOOKBACK_DAYS * 86_400_000);
|
|
390
|
+
const sinceDate = dayBefore(since.toISOString().slice(0, 10));
|
|
391
|
+
// "Still open" is a question about the punches of one person-day, and it used to be
|
|
392
|
+
// answered here — a query per candidate row, before anything was known about the person
|
|
393
|
+
// it belonged to. As an anti-join it costs nothing extra and the loop below only ever
|
|
394
|
+
// sees shifts that really are open.
|
|
395
|
+
//
|
|
396
|
+
// The three predicates above it are the ones `hr_punches_open_idx` is built from and
|
|
397
|
+
// must not move: `voided_by_punch_id is null` is the index's partial predicate because
|
|
398
|
+
// drizzle emits `is null` literally and the planner can prove the implication, while
|
|
399
|
+
// `direction` arrives as a bind parameter, which a predicate's constant cannot match.
|
|
400
|
+
const closing = alias(punches, 'closing');
|
|
260
401
|
const open = await tx
|
|
261
402
|
.select({
|
|
262
403
|
personId: punches.personId,
|
|
@@ -264,46 +405,76 @@ export function hrJobs() {
|
|
|
264
405
|
at: punches.at,
|
|
265
406
|
})
|
|
266
407
|
.from(punches)
|
|
267
|
-
.where(and(eq(punches.workspaceId, workspaceId), eq(punches.direction, 'in'), isNull(punches.voidedByPunchId), lte(punches.at, cutoff)))
|
|
408
|
+
.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
|
|
409
|
+
.select({ closed: sql `1` })
|
|
410
|
+
.from(closing)
|
|
411
|
+
.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),
|
|
412
|
+
// Implied by the equality above, and stated anyway: without it nothing
|
|
413
|
+
// bounds the date on this side, and the planner then drives the anti-join
|
|
414
|
+
// from here — reading every populated partition for `out` punches to
|
|
415
|
+
// find the handful that close anything. Measured at 2,949 buffers
|
|
416
|
+
// against 865 with the bounds, on the same rows.
|
|
417
|
+
gte(closing.businessDate, sinceDate), gte(closing.at, since))))));
|
|
418
|
+
// One shift per person-day, not one per `in` punch. Somebody who clocked in twice and
|
|
419
|
+
// never out has one open shift, and closing it twice would write two clock-outs for a
|
|
420
|
+
// day that had one. Deduping here rather than with `distinct on` keeps the statement's
|
|
421
|
+
// plan on the index above instead of on whatever answers the ordering cheapest.
|
|
422
|
+
const shifts = new Map();
|
|
268
423
|
for (const row of open) {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
if (!
|
|
272
|
-
|
|
273
|
-
|
|
424
|
+
const key = `${row.personId}:${row.businessDate}`;
|
|
425
|
+
const seen = shifts.get(key);
|
|
426
|
+
if (!seen || row.at > seen.at)
|
|
427
|
+
shifts.set(key, row);
|
|
428
|
+
}
|
|
429
|
+
if (!shifts.size)
|
|
430
|
+
return;
|
|
431
|
+
// One ladder walk per business date rather than one per shift. The resolution is what
|
|
432
|
+
// says which office — and so which zone — the day belongs to, and it is asked **as of
|
|
433
|
+
// that day**: a sweep that asks as of today closes a transferred employee's old shift
|
|
434
|
+
// in their new office's time.
|
|
435
|
+
const perDate = new Map();
|
|
436
|
+
for (const row of shifts.values())
|
|
437
|
+
perDate.set(row.businessDate, [...(perDate.get(row.businessDate) ?? []), row.personId]);
|
|
438
|
+
const zones = new Map();
|
|
439
|
+
for (const [businessDate, personIds] of perDate)
|
|
440
|
+
for (const [personId, r] of await resolve.forPeople(tx, workspaceId, personIds, businessDate))
|
|
441
|
+
zones.set(`${personId}:${businessDate}`, r.timezone);
|
|
442
|
+
for (const row of shifts.values()) {
|
|
274
443
|
const schedule = await attendance.scheduleFor(tx, workspaceId, row.personId, row.businessDate);
|
|
275
444
|
if (!schedule.autoClockOutAfterMinutes)
|
|
276
445
|
continue;
|
|
277
446
|
if (Date.now() - row.at.getTime() < schedule.autoClockOutAfterMinutes * 60_000)
|
|
278
447
|
continue;
|
|
448
|
+
const timezone = zones.get(`${row.personId}:${row.businessDate}`) ?? 'UTC';
|
|
279
449
|
await tx.insert(punches).values({
|
|
280
450
|
workspaceId,
|
|
281
451
|
personId: row.personId,
|
|
282
452
|
direction: 'out',
|
|
283
453
|
at: new Date(row.at.getTime() + schedule.autoClockOutAfterMinutes * 60_000),
|
|
284
454
|
businessDate: row.businessDate,
|
|
285
|
-
timezone
|
|
455
|
+
timezone,
|
|
286
456
|
method: 'manual',
|
|
287
457
|
trust: 'trusted',
|
|
288
458
|
note: 'Closed automatically: no clock-out recorded',
|
|
289
459
|
});
|
|
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');
|
|
460
|
+
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
461
|
+
kernel.log.info({ module: 'hr', personId: row.personId, businessDate: row.businessDate, timezone }, 'auto clock-out');
|
|
292
462
|
}
|
|
293
463
|
});
|
|
294
464
|
},
|
|
295
465
|
},
|
|
296
466
|
{
|
|
297
467
|
/**
|
|
298
|
-
* Rebuild recent
|
|
468
|
+
* Rebuild every recent day a period does not close.
|
|
299
469
|
*
|
|
300
470
|
* Punches recompute their own day inline, so this exists for what that path cannot see: a
|
|
301
471
|
* calendar edited after the fact, a schedule changed retroactively, an enqueue that never
|
|
302
472
|
* ran. Anything it finds and changes is a bug worth knowing about rather than routine
|
|
303
473
|
* maintenance — which is why it logs a count instead of running silently.
|
|
304
474
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
475
|
+
* A day a period really does close is still never touched — a filed payroll must not move
|
|
476
|
+
* underneath itself — but that is `recomputeDay`'s answer to give, not this query's. `touched`
|
|
477
|
+
* counts the days it actually rebuilt, so a window full of a closed month still logs nothing.
|
|
307
478
|
*/
|
|
308
479
|
name: 'reconcile-days',
|
|
309
480
|
cron: '30 2 * * *',
|
|
@@ -321,11 +492,18 @@ export function hrJobs() {
|
|
|
321
492
|
businessDate: attendanceDays.businessDate,
|
|
322
493
|
})
|
|
323
494
|
.from(attendanceDays)
|
|
324
|
-
.where(and(eq(attendanceDays.workspaceId, workspaceId),
|
|
495
|
+
.where(and(eq(attendanceDays.workspaceId, workspaceId), sql `${attendanceDays.businessDate} >= ${since}`));
|
|
325
496
|
let touched = 0;
|
|
326
497
|
for (const day of days) {
|
|
327
498
|
const resolution = await resolve.forPerson(tx, workspaceId, day.personId, day.businessDate);
|
|
328
499
|
const schedule = await attendance.scheduleFor(tx, workspaceId, day.personId, day.businessDate);
|
|
500
|
+
// Every row in the window is offered, whatever its flag says. `locked` is a cache of
|
|
501
|
+
// an answer only the period holds, and `recomputeDay` is the one place entitled to
|
|
502
|
+
// read it — in both directions, since a flag repaired only upwards is a trapdoor
|
|
503
|
+
// rather than a cache. Selecting `locked = false` here excluded exactly the rows the
|
|
504
|
+
// downward repair exists for: a day stamped by a lock that has since been reopened,
|
|
505
|
+
// or by somebody's employment being corrected underneath one, could never be visited
|
|
506
|
+
// by anything a running instance does. A repair nothing reaches is not a repair.
|
|
329
507
|
const r = await attendance.recomputeDay(tx, workspaceId, day.personId, day.businessDate, resolution.timezone, schedule);
|
|
330
508
|
if (!r.locked)
|
|
331
509
|
touched++;
|
|
@@ -347,5 +525,141 @@ async function activeWorkspaces(kernel) {
|
|
|
347
525
|
const { rows } = await kernel.database.pool.query(`select distinct workspace_id from mod_hr.offices where archived_at is null`);
|
|
348
526
|
return rows.map((r) => r.workspace_id);
|
|
349
527
|
}
|
|
350
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Every live office of a workspace, with the date it is on at `at`.
|
|
530
|
+
*
|
|
531
|
+
* The whole fan-out is this one function: a job asks which offices have crossed the boundary it
|
|
532
|
+
* cares about instead of asking a cron expression, which only ever knows UTC.
|
|
533
|
+
*/
|
|
534
|
+
async function officeDays(tx, workspaceId, at) {
|
|
535
|
+
const rows = await tx
|
|
536
|
+
.select({ id: offices.id, name: offices.name, timezone: offices.timezone })
|
|
537
|
+
.from(offices)
|
|
538
|
+
.where(and(eq(offices.workspaceId, workspaceId), isNull(offices.archivedAt)));
|
|
539
|
+
return rows.map((o) => ({ ...o, today: todayIn(o.timezone, at) }));
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Which of these people a set of offices decides for, on a date.
|
|
543
|
+
*
|
|
544
|
+
* Only the primary office votes, and `ResolveService` is the only implementation of that — so the
|
|
545
|
+
* question is asked there rather than by joining `office_assignments` here and growing a second,
|
|
546
|
+
* subtly different ladder. Nobody falls out of every group: a workspace always has a default
|
|
547
|
+
* office, and it answers for anyone without an assignment of their own.
|
|
548
|
+
*/
|
|
549
|
+
async function inOffices(resolve, tx, workspaceId, personIds, on, officeIds) {
|
|
550
|
+
const resolutions = await resolve.forPeople(tx, workspaceId, personIds, on);
|
|
551
|
+
return personIds.filter((id) => {
|
|
552
|
+
const officeId = resolutions.get(id)?.primaryOfficeId;
|
|
553
|
+
return !!officeId && officeIds.has(officeId);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Take away carried leave that has passed its deadline, and say so on the record.
|
|
558
|
+
*
|
|
559
|
+
* The deadline is **recomputed** from the carry-forward policy in force on the 1st of January the
|
|
560
|
+
* leave was carried into, rather than parsed back out of the entry that carried it: a policy is
|
|
561
|
+
* effective-dated, so the same question asked of the same date gives the same answer for ever, and
|
|
562
|
+
* a date encoded in a `reason` string is a column nobody declared.
|
|
563
|
+
*
|
|
564
|
+
* How much is left of the carry is a FIFO question — carried days are spent before the new year's
|
|
565
|
+
* own accrual, so what lapses is the carry minus everything spent since, and never more than the
|
|
566
|
+
* balance actually standing. That also makes the sweep idempotent without a marker: the entry it
|
|
567
|
+
* writes is itself spending, so the second run finds nothing left to take.
|
|
568
|
+
*/
|
|
569
|
+
async function lapseCarriedLeave(args) {
|
|
570
|
+
const { tx, workspaceId, ledger, policySvc, today, personIds, typeByKey } = args;
|
|
571
|
+
const thisYear = Number(today.slice(0, 4));
|
|
572
|
+
// Only the years that actually carried anything, and only as far back as a deadline can reach:
|
|
573
|
+
// `expiresAfterMonths` is capped at 24 by the contract.
|
|
574
|
+
const carriedYears = await tx
|
|
575
|
+
.selectDistinct({ periodYear: leaveLedger.periodYear })
|
|
576
|
+
.from(leaveLedger)
|
|
577
|
+
.where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.kind, 'carry_in'), inArray(leaveLedger.personId, personIds), gte(leaveLedger.periodYear, thisYear - 2), lte(leaveLedger.periodYear, thisYear)));
|
|
578
|
+
if (!carriedYears.length)
|
|
579
|
+
return 0;
|
|
580
|
+
let lapsed = 0;
|
|
581
|
+
for (const { periodYear } of carriedYears) {
|
|
582
|
+
const yearStart = `${periodYear}-01-01`;
|
|
583
|
+
const policies = await policySvc.forPeople(tx, workspaceId, personIds, 'carry_forward', yearStart);
|
|
584
|
+
const due = personIds.filter((personId) => {
|
|
585
|
+
const config = policies.get(personId)?.config;
|
|
586
|
+
if (!config)
|
|
587
|
+
return false;
|
|
588
|
+
const expiresOn = carryExpiryDate(yearStart, config.expiresAfterMonths ?? null);
|
|
589
|
+
return carryHasLapsed(today, expiresOn);
|
|
590
|
+
});
|
|
591
|
+
if (!due.length)
|
|
592
|
+
continue;
|
|
593
|
+
const sums = await tx
|
|
594
|
+
.select({
|
|
595
|
+
personId: leaveLedger.personId,
|
|
596
|
+
leaveTypeId: leaveLedger.leaveTypeId,
|
|
597
|
+
kind: leaveLedger.kind,
|
|
598
|
+
total: sql `sum(${leaveLedger.amountMinutes})::int`,
|
|
599
|
+
})
|
|
600
|
+
.from(leaveLedger)
|
|
601
|
+
.where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.periodYear, periodYear), inArray(leaveLedger.personId, due)))
|
|
602
|
+
.groupBy(leaveLedger.personId, leaveLedger.leaveTypeId, leaveLedger.kind);
|
|
603
|
+
const tally = new Map();
|
|
604
|
+
for (const row of sums) {
|
|
605
|
+
const key = `${row.personId}:${row.leaveTypeId}`;
|
|
606
|
+
const t = tally.get(key) ?? { carriedIn: 0, balance: 0, spent: 0 };
|
|
607
|
+
const total = Number(row.total);
|
|
608
|
+
t.balance += total;
|
|
609
|
+
if (row.kind === 'carry_in')
|
|
610
|
+
t.carriedIn += total;
|
|
611
|
+
if (total < 0)
|
|
612
|
+
t.spent += -total;
|
|
613
|
+
tally.set(key, t);
|
|
614
|
+
}
|
|
615
|
+
for (const personId of due) {
|
|
616
|
+
const config = policies.get(personId)?.config;
|
|
617
|
+
const type = typeByKey.get(config.leaveTypeKey);
|
|
618
|
+
if (!type)
|
|
619
|
+
continue;
|
|
620
|
+
const expiresOn = carryExpiryDate(yearStart, config.expiresAfterMonths ?? null);
|
|
621
|
+
if (!expiresOn)
|
|
622
|
+
continue;
|
|
623
|
+
const t = tally.get(`${personId}:${type.id}`);
|
|
624
|
+
if (!t?.carriedIn)
|
|
625
|
+
continue;
|
|
626
|
+
const remaining = Math.min(Math.max(0, t.carriedIn - t.spent), t.balance);
|
|
627
|
+
if (remaining <= 0)
|
|
628
|
+
continue;
|
|
629
|
+
await ledger.lockAndRead(tx, workspaceId, personId, type.id, periodYear);
|
|
630
|
+
await ledger.append(tx, workspaceId, {
|
|
631
|
+
personId,
|
|
632
|
+
leaveTypeId: type.id,
|
|
633
|
+
kind: 'expiry',
|
|
634
|
+
amountMinutes: -remaining,
|
|
635
|
+
effectiveOn: expiresOn,
|
|
636
|
+
periodYear,
|
|
637
|
+
reason: `Carried leave not used by ${dayBefore(expiresOn)}`,
|
|
638
|
+
});
|
|
639
|
+
lapsed++;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return lapsed;
|
|
643
|
+
}
|
|
644
|
+
/** The month before the one a `YYYY-MM-01` names, as its own first and last day. */
|
|
645
|
+
function monthBefore(firstOfMonth) {
|
|
646
|
+
const [y, m] = firstOfMonth.split('-').map(Number);
|
|
647
|
+
const year = m === 1 ? y - 1 : y;
|
|
648
|
+
const month = m === 1 ? 12 : m - 1;
|
|
649
|
+
const mm = String(month).padStart(2, '0');
|
|
650
|
+
const last = String(daysInMonth(year, month)).padStart(2, '0');
|
|
651
|
+
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${last}` };
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The day before a `YYYY-MM-DD`.
|
|
655
|
+
*
|
|
656
|
+
* An expiry date is the first day the leave is *gone*, which is not the date to show somebody or to
|
|
657
|
+
* bound a sweep with — "use by 31 March" and "expires 1 April" are the same rule and only one of
|
|
658
|
+
* them reads as an instruction.
|
|
659
|
+
*/
|
|
660
|
+
function dayBefore(date) {
|
|
661
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
662
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
663
|
+
return d.toISOString().slice(0, 10);
|
|
664
|
+
}
|
|
351
665
|
//# sourceMappingURL=jobs.js.map
|