@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.
Files changed (90) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.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 +9 -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 +458 -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 +1896 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +146 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +53 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +101 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +14 -0
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +14 -3
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -6
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -13
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/policies.d.ts +6 -0
  44. package/dist/server/services/policies.d.ts.map +1 -1
  45. package/dist/server/services/policies.js +6 -0
  46. package/dist/server/services/policies.js.map +1 -1
  47. package/migrations/0005_approval_requester.sql +30 -0
  48. package/migrations/0006_schedule_no_overlap.sql +85 -0
  49. package/migrations/0007_hot_path_indexes.sql +38 -0
  50. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  51. package/migrations/meta/0005_snapshot.json +4047 -0
  52. package/migrations/meta/0007_snapshot.json +4225 -0
  53. package/migrations/meta/0009_snapshot.json +4231 -0
  54. package/migrations/meta/_journal.json +28 -0
  55. package/package.json +7 -7
  56. package/src/client/capabilities.ts +34 -0
  57. package/src/client/components/ClockControls.svelte +127 -20
  58. package/src/client/components/ClockControls.test.ts +446 -0
  59. package/src/client/components/DecisionDialog.svelte +90 -0
  60. package/src/client/components/DelegationDialog.svelte +219 -0
  61. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  62. package/src/client/components/PersonFormDialog.svelte +53 -3
  63. package/src/client/components/PersonPanel.svelte +134 -26
  64. package/src/client/i18n.ts +5 -625
  65. package/src/client/index.ts +1 -20
  66. package/src/client/messages.test.ts +154 -0
  67. package/src/client/messages.ts +3388 -0
  68. package/src/client/mock.ts +1691 -162
  69. package/src/client/module.ts +17 -2
  70. package/src/client/pages/ApprovalsPage.svelte +332 -60
  71. package/src/client/pages/AttendancePage.svelte +106 -20
  72. package/src/client/pages/DirectoryPage.svelte +192 -55
  73. package/src/client/pages/LeavePage.svelte +280 -25
  74. package/src/client/pages/OfficesPage.svelte +181 -39
  75. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  76. package/src/client/permissions.ts +2 -1
  77. package/src/client/query.ts +22 -1
  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 +43 -0
  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/approvals.ts +13 -1
  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,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
- * 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
+ 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 the turn of the entitlement year.
187
+ * Carry-forward and expiry, on each office's own turn of the year.
149
188
  *
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.
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 4 2 1 *',
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 thisYear = new Date().getUTCFullYear();
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 ids = staff.map((p) => p.id);
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
- let moved = 0;
176
- for (const person of staff) {
177
- const policy = resolved.get(person.id);
178
- if (!policy?.config)
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 config = policy.config;
181
- const type = typeByKey.get(config.leaveTypeKey);
182
- if (!type)
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 minutesPerDay = 8 * 60;
185
- const balance = await ledger.lockAndRead(tx, workspaceId, person.id, type.id, lastYear);
186
- if (balance <= 0)
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 { carriedMinutes, expiredMinutes } = carryForward(balance, {
189
- maxMinutes: Math.round(config.maxDays * minutesPerDay),
190
- expiresAfterMonths: config.expiresAfterMonths ?? null,
326
+ const lapsed = await lapseCarriedLeave({
327
+ tx,
328
+ workspaceId,
329
+ ledger,
330
+ policySvc,
331
+ today,
332
+ personIds: ids,
333
+ typeByKey,
191
334
  });
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
- }
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 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.
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 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);
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: resolution.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, resolution.timezone, schedule);
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 unlocked days.
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
- * Locked days are never touched: a closed month must not move underneath a payroll already
306
- * filed.
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), eq(attendanceDays.locked, false), sql `${attendanceDays.businessDate} >= ${since}`));
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
- export { inArray, offices, todayIn };
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