@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.
Files changed (90) hide show
  1. package/dist/contract/attendance.d.ts +1 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +9 -0
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +1 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +489 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1888 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +154 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +17 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +81 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +0 -2
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +12 -4
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -8
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -14
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/ledger.d.ts +0 -2
  44. package/dist/server/services/ledger.d.ts.map +1 -1
  45. package/dist/server/services/ledger.js +1 -2
  46. package/dist/server/services/ledger.js.map +1 -1
  47. package/dist/server/services/policies.d.ts +6 -2
  48. package/dist/server/services/policies.d.ts.map +1 -1
  49. package/dist/server/services/policies.js +7 -2
  50. package/dist/server/services/policies.js.map +1 -1
  51. package/migrations/0005_approval_requester.sql +11 -1
  52. package/migrations/0006_schedule_no_overlap.sql +85 -0
  53. package/migrations/0007_hot_path_indexes.sql +38 -0
  54. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  55. package/migrations/meta/0005_snapshot.json +4047 -0
  56. package/migrations/meta/0007_snapshot.json +4225 -0
  57. package/migrations/meta/0009_snapshot.json +4231 -0
  58. package/migrations/meta/_journal.json +21 -0
  59. package/package.json +7 -7
  60. package/src/client/components/ClockControls.svelte +127 -20
  61. package/src/client/components/ClockControls.test.ts +446 -0
  62. package/src/client/components/DelegationDialog.svelte +12 -3
  63. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  64. package/src/client/components/PersonFormDialog.svelte +53 -3
  65. package/src/client/components/PersonPanel.svelte +134 -26
  66. package/src/client/i18n.ts +5 -927
  67. package/src/client/messages.test.ts +154 -0
  68. package/src/client/messages.ts +3388 -0
  69. package/src/client/mock.ts +1576 -161
  70. package/src/client/module.ts +15 -1
  71. package/src/client/pages/ApprovalsPage.svelte +120 -46
  72. package/src/client/pages/AttendancePage.svelte +106 -20
  73. package/src/client/pages/DirectoryPage.svelte +192 -55
  74. package/src/client/pages/LeavePage.svelte +280 -25
  75. package/src/client/pages/OfficesPage.svelte +26 -8
  76. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  77. package/src/client/query.ts +12 -0
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +14 -7
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/attendance.ts +9 -0
  90. package/src/contract/capabilities.ts +17 -0
@@ -1,19 +1,40 @@
1
- import { and, eq, inArray, isNull, lte, sql } from 'drizzle-orm';
2
- import { accrueForPeriod, carryForward } from '../policy/accrual.js';
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 office, deciding for each whether *that office's*
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
- * Runs on the 1st for the month that just ended, per office rather than once in UTC — "the
55
- * month has ended" is a different moment in Istanbul and Amsterdam, and a single UTC-timed
56
- * run credits one of them a day early.
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 credits only what is missing.
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 2 1 * *',
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
- // The month that just ended, computed by Postgres so month lengths and leap years are
71
- // its problem rather than this file's.
72
- const bounds = await tx.execute(sql `
73
- select (date_trunc('month', now()) - interval '1 month')::date::text as from,
74
- (date_trunc('month', now()) - interval '1 day')::date::text as to
75
- `);
76
- const previous = bounds.rows[0];
77
- if (!previous)
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 ids = staff.map((p) => p.id);
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 employmentRows = await tx
94
- .select()
95
- .from(employments)
96
- .where(and(eq(employments.workspaceId, workspaceId), inArray(employments.personId, ids), isNull(employments.effectiveTo)));
97
- const employmentBy = new Map(employmentRows.map((e) => [e.personId, e]));
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 year = Number(to.slice(0, 4));
129
- await ledger.lockAndRead(tx, workspaceId, person.id, type.id, year);
130
- await ledger.append(tx, workspaceId, {
131
- personId: person.id,
132
- leaveTypeId: type.id,
133
- kind: 'accrual',
134
- amountMinutes: result.minutes,
135
- effectiveOn: to,
136
- periodYear: year,
137
- reason: result.reason,
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
+ /**
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 the turn of the entitlement year.
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
- * Writes **both halves**: what carried and what lapsed, as separate ledger entries. A balance
151
- * that silently shrinks at midnight on 1 January is the most disputed number in any leave
152
- * system, and "you had 9 days, 5 carried, 4 expired under the cap" is a sentence somebody can
153
- * check. Runs on the 2nd so a late December accrual has already landed.
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 4 2 1 *',
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 thisYear = new Date().getUTCFullYear();
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 ids = staff.map((p) => p.id);
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
- let moved = 0;
176
- for (const person of staff) {
177
- const policy = resolved.get(person.id);
178
- if (!policy?.config)
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 config = policy.config;
181
- const type = typeByKey.get(config.leaveTypeKey);
182
- if (!type)
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 minutesPerDay = 8 * 60;
185
- const balance = await ledger.lockAndRead(tx, workspaceId, person.id, type.id, lastYear);
186
- if (balance <= 0)
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 { carriedMinutes, expiredMinutes } = carryForward(balance, {
189
- maxMinutes: Math.round(config.maxDays * minutesPerDay),
190
- expiresAfterMonths: config.expiresAfterMonths ?? null,
357
+ const lapsed = await lapseCarriedLeave({
358
+ tx,
359
+ workspaceId,
360
+ ledger,
361
+ policySvc,
362
+ today,
363
+ personIds: ids,
364
+ typeByKey,
191
365
  });
192
- // The old year is closed out in full, then what survives opens the new one. Two
193
- // entries rather than a transfer, so each year's ledger sums to what that year held.
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 per office rather than globally: "it is past 3am" is a different moment in every
237
- * office, and a single UTC-timed sweep would close a Tehran shift mid-afternoon.
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 rows = await attendance.punchesOn(tx, workspaceId, row.personId, row.businessDate);
270
- const stillOpen = rows.reduce((acc, r) => (r.direction === 'in' ? true : r.direction === 'out' ? false : acc), false);
271
- if (!stillOpen)
272
- continue;
273
- const resolution = await resolve.forPerson(tx, workspaceId, row.personId);
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: resolution.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, resolution.timezone, schedule);
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 unlocked days.
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
- * Locked days are never touched: a closed month must not move underneath a payroll already
306
- * filed.
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), eq(attendanceDays.locked, false), sql `${attendanceDays.businessDate} >= ${since}`));
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
- export { inArray, offices, todayIn };
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