@kernhq/module-hr 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ import { KernError } from '@kernhq/kernel';
2
+ import { and, eq, gte, inArray, isNull, lte, or, sql } from 'drizzle-orm';
3
+ import { MAX_PERSON_DAYS, MAX_REPORT_DAYS, MAX_SLICED_REPORT_DAYS } from '../../contract/reports.js';
4
+ import { datesBetween, workingDays } from '../../policy/calendar.js';
5
+ import { attendanceDays, employments, leaveLedger, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, people, scheduleAssignments, } from '../schema.js';
6
+ import { toUnit } from './ledger.js';
7
+ /**
8
+ * The four reports, and the arithmetic they are not allowed to guess at.
9
+ *
10
+ * Everything above the class is pure — no `tx`, no clock, no kernel — because the decisions worth
11
+ * pinning are decisions about *unknown*, and they are impossible to see in a query plan. Zero and
12
+ * unknown are different answers, and a report that conflates them is wrong in the direction nobody
13
+ * checks: a 100% attendance figure for somebody with no schedule, a confident "21 expected working
14
+ * days" for an office whose calendar is Monday–Friday only because nothing was attached, an
15
+ * overtime ceiling reported as "nothing exceeded" where no ceiling ever applied. Each of those
16
+ * renders as a plausible number, and `reports.test.ts` is what holds them to `null`.
17
+ *
18
+ * Everything below the class is aggregated **in the database**. The one thing that cannot be — a
19
+ * person's expected working days, which is calendar arithmetic living in `policy/calendar.ts` and
20
+ * must not grow a second implementation in SQL — is pushed down as a `(date, fraction)` array of at
21
+ * most one range's length, so the join and the sum still happen in Postgres. Nothing here reads a
22
+ * year of day sheets into the process to add them up.
23
+ */
24
+ // ====================================================================== pure
25
+ /** Halves and quarters add up exactly; floating point does not. Two places is enough for both. */
26
+ export const round2 = (n) => Math.round(n * 100) / 100;
27
+ /** Inclusive day count of a range, and `0` for a reversed one so a caller can refuse it. */
28
+ export function rangeDays(from, to) {
29
+ return to < from ? 0 : datesBetween(from, to).length;
30
+ }
31
+ /**
32
+ * A ratio, or **null when the denominator is not a number this module holds**.
33
+ *
34
+ * The case that matters is `worked / scheduled` for somebody with no schedule assignment:
35
+ * `scheduleFor` returns `NO_SCHEDULE`, `computeDay` writes `scheduledMinutes: 0`, and the honest
36
+ * answer to "what proportion of nothing did she work" is not 0% and not 100%. A screen renders this
37
+ * as an em dash.
38
+ */
39
+ export function ratio(numerator, denominator) {
40
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0)
41
+ return null;
42
+ return round2(numerator / denominator);
43
+ }
44
+ export function scheduleState(policyHash) {
45
+ if (policyHash === null || policyHash === undefined || policyHash === '')
46
+ return 'unknown';
47
+ return policyHash.startsWith('none:') ? 'none' : 'scheduled';
48
+ }
49
+ /**
50
+ * Total a column where **null means "not applicable"**, never zero.
51
+ *
52
+ * `attendance_days.beyond_cap_minutes` is nullable on purpose: null is "no annual ceiling was in
53
+ * force on this day", zero is "one was and nothing passed it". Summing with `coalesce(…, 0)`
54
+ * destroys exactly the distinction the column exists for — and it is the distinction a statutory
55
+ * ceiling conversation turns on. So a person with no capped day at all reports `null`, and one with
56
+ * a single capped day reports that day's figure even where it is zero.
57
+ */
58
+ export function capTotal(values) {
59
+ let sum = 0;
60
+ let capped = 0;
61
+ let uncapped = 0;
62
+ for (const value of values) {
63
+ if (value === null)
64
+ uncapped++;
65
+ else {
66
+ capped++;
67
+ sum += value;
68
+ }
69
+ }
70
+ return { beyondCapMinutes: capped === 0 ? null : sum, cappedDays: capped, uncappedDays: uncapped };
71
+ }
72
+ /**
73
+ * Combine per-person or per-query finality into the report's.
74
+ *
75
+ * A range that crosses a lock boundary is neither final nor provisional, and the two halves have to
76
+ * be nameable — "1–15 October locked, 16–31 October provisional" — because tonight's
77
+ * `reconcile-days` will move the open half. A workspace with no periods at all has every day open,
78
+ * which is the ordinary state and not a warning: the `periods` capability ships off.
79
+ */
80
+ export function mergeFinality(parts) {
81
+ let lockedDays = 0;
82
+ let openDays = 0;
83
+ let firstOpenDay = null;
84
+ let lastLockedDay = null;
85
+ for (const part of parts) {
86
+ lockedDays += part.lockedDays;
87
+ openDays += part.openDays;
88
+ if (part.firstOpenDay && (firstOpenDay === null || part.firstOpenDay < firstOpenDay))
89
+ firstOpenDay = part.firstOpenDay;
90
+ if (part.lastLockedDay && (lastLockedDay === null || part.lastLockedDay > lastLockedDay))
91
+ lastLockedDay = part.lastLockedDay;
92
+ }
93
+ // `final` needs at least one locked day as well as no open one: a report over days that produced
94
+ // no sheet at all has nothing to declare final, and saying it did would be the strongest claim
95
+ // available made from the least evidence.
96
+ return { lockedDays, openDays, final: lockedDays > 0 && openDays === 0, firstOpenDay, lastLockedDay };
97
+ }
98
+ /**
99
+ * Expected days split into worked, excused and absent.
100
+ *
101
+ * `leaveDays` is null when the `leave` capability is off, and then nothing is subtracted for leave —
102
+ * the report says `leaveCounted: false` beside the figure rather than showing a zero that reads as
103
+ * "nobody was on leave". Floored at zero because somebody can work a public holiday, and a negative
104
+ * absence is not a fact.
105
+ */
106
+ export function absenceSplit(input) {
107
+ const absentDays = Math.max(0, round2(input.expectedDays - input.workedDays - (input.leaveDays ?? 0)));
108
+ return { absentDays, absenceRate: ratio(absentDays, input.expectedDays) };
109
+ }
110
+ /**
111
+ * Why a person has no expected-days figure, or `calendar` when they do.
112
+ *
113
+ * Two exclusions, both named rather than silent, and both of them populations a naive report gets
114
+ * confidently wrong:
115
+ *
116
+ * - **No schedule.** Salaried staff who never clock in have no schedule assignment, owe no hours and
117
+ * are not absent. In most workspaces that switch attendance on for a subset they are the majority,
118
+ * and counting them absent would put every one of them at 100%.
119
+ * - **No calendar.** `offices.calendarId` is nullable and the ladder then falls back to Monday–
120
+ * Friday. The arithmetic succeeds and is silently an assumption — wrong for every office whose
121
+ * weekend is Friday. A calendar that is attached but has no holidays in the range is a real
122
+ * answer; no calendar at all is not.
123
+ */
124
+ export function absenceBasis(input) {
125
+ if (!input.hasSchedule)
126
+ return 'no_schedule';
127
+ if (!input.hasCalendar)
128
+ return 'no_calendar';
129
+ return 'calendar';
130
+ }
131
+ /**
132
+ * Whether a report of this shape may be run, and the sentence to refuse it with.
133
+ *
134
+ * Returns null when it may. The refusal carries both numbers rather than a limit nobody can act on,
135
+ * because the reader's next move — narrow the slice, or shorten the range — depends on which of the
136
+ * two is large.
137
+ */
138
+ export function rangeRefusal(input) {
139
+ const days = rangeDays(input.from, input.to);
140
+ if (days === 0)
141
+ return `${input.to} is before ${input.from}.`;
142
+ const max = input.perDay ? MAX_SLICED_REPORT_DAYS : MAX_REPORT_DAYS;
143
+ if (days > max)
144
+ return input.perDay
145
+ ? `A report attributed day by day covers at most ${max} days, and this one asks for ${days}. Ask for a shorter range, or drop the slice.`
146
+ : `A report covers at most ${max} days, and this one asks for ${days}.`;
147
+ if (input.perDay && input.population !== undefined) {
148
+ const cells = input.population * days;
149
+ if (cells > MAX_PERSON_DAYS)
150
+ return `${input.population} people over ${days} days is ${cells} person-days, and this report resolves at most ${MAX_PERSON_DAYS}. Ask for one office, or a shorter range.`;
151
+ }
152
+ return null;
153
+ }
154
+ /** One person's balance in one leave type, assembled from the three things that decide it. */
155
+ export function balanceRow(input) {
156
+ const availableMinutes = input.balanceMinutes - input.pendingMinutes;
157
+ return {
158
+ balanceMinutes: input.balanceMinutes,
159
+ bookedMinutes: input.bookedMinutes,
160
+ pendingMinutes: input.pendingMinutes,
161
+ availableMinutes,
162
+ balance: toUnit(input.balanceMinutes, input.unit),
163
+ available: toUnit(availableMinutes, input.unit),
164
+ };
165
+ }
166
+ // ====================================================================== database
167
+ const int = (value) => {
168
+ const n = Number(value ?? 0);
169
+ return Number.isFinite(n) ? n : 0;
170
+ };
171
+ /**
172
+ * A Postgres array literal, spelled out.
173
+ *
174
+ * `sql\`${someArray}::uuid[]\`` looks like it binds one array parameter and does not: drizzle
175
+ * expands a JS array into a parameter **list**, so it renders `($1, $2)::uuid[]` and Postgres
176
+ * rejects the statement. Nothing catches that — the types are fine, and a pure-function test never
177
+ * reaches a database — so every report would have thrown on its first real call. `array[$1, $2]` is
178
+ * the spelling that survives, and it is needed wherever a column is not available for drizzle's own
179
+ * `inArray` (an aliased table inside a raw `from`, or an `unnest` of two parallel arrays).
180
+ */
181
+ const pgArray = (values, type) => sql `array[${sql.join(values.map((v) => sql `${v}`), sql `, `)}]::${sql.raw(type)}`;
182
+ export class ReportsService {
183
+ resolve;
184
+ constructor(resolve) {
185
+ this.resolve = resolve;
186
+ }
187
+ /**
188
+ * Who the report is about, and on which days.
189
+ *
190
+ * **Attributed as of each day, not as of today.** Every list handler in this module filters on an
191
+ * assignment with no end date, and every *number* in it resolves per date instead — a day sheet is
192
+ * rebuilt against the entity in force on the day it covers, a period lock resolves once per date,
193
+ * the accrual job resolves on the last day of the period. A report is a number, so copying the
194
+ * nearest list handler would hand a transfer's whole previous quarter to the receiving office.
195
+ *
196
+ * The ladder is asked rather than reimplemented. `ResolveService` is the only thing that knows
197
+ * that a person may hold several concurrent office assignments and that only the primary decides,
198
+ * or that a legal entity falls back from the employment to the office. What is done here is a
199
+ * cheap **superset** query first — everybody who could conceivably be in this slice — so the
200
+ * ladder walks over an office rather than over a workspace. A superset that is too wide costs
201
+ * time and never correctness; the answer still comes from `forPeople`.
202
+ */
203
+ async population(tx, workspaceId, slice, from, to) {
204
+ if (slice.by === 'workspace' || !slice.id) {
205
+ const rows = await tx.select({ id: people.id }).from(people).where(eq(people.workspaceId, workspaceId));
206
+ return {
207
+ personIds: rows.map((r) => r.id),
208
+ datesByPerson: null,
209
+ resolutions: null,
210
+ sliceName: null,
211
+ };
212
+ }
213
+ const superset = await this.superset(tx, workspaceId, slice.by, slice.id, from, to);
214
+ const dates = datesBetween(from, to);
215
+ const resolutions = await this.resolveByDate(tx, workspaceId, superset, dates);
216
+ const datesByPerson = new Map();
217
+ for (const date of dates) {
218
+ for (const [personId, resolution] of resolutions.get(date) ?? []) {
219
+ const here = slice.by === 'office' ? resolution.primaryOfficeId : resolution.legalEntityId;
220
+ if (here !== slice.id)
221
+ continue;
222
+ const list = datesByPerson.get(personId);
223
+ if (list)
224
+ list.push(date);
225
+ else
226
+ datesByPerson.set(personId, [date]);
227
+ }
228
+ }
229
+ return {
230
+ personIds: [...datesByPerson.keys()],
231
+ datesByPerson,
232
+ resolutions,
233
+ sliceName: await this.sliceName(tx, workspaceId, slice.by, slice.id),
234
+ };
235
+ }
236
+ /**
237
+ * Everybody who could be in this slice on any day of the range.
238
+ *
239
+ * Deliberately loose. An office assignment that is not primary still puts somebody in the
240
+ * superset, because whether it is primary on a given date is the ladder's answer and not this
241
+ * query's. The workspace's default office is the one case with no narrowing available at all:
242
+ * anybody with no primary assignment on a date falls back to it, so slicing by the default office
243
+ * — or by the entity it belongs to — has to consider the whole directory.
244
+ */
245
+ async superset(tx, workspaceId, by, id, from, to) {
246
+ // `inForceOn` next door answers "in force on this date"; the superset asks the range version of
247
+ // the same question, because somebody who was in this office for a fortnight of the range still
248
+ // belongs in the set the ladder is then asked about day by day.
249
+ const overlapsRange = (fromCol, toCol) => and(lte(fromCol, to), or(isNull(toCol), gte(toCol, from)));
250
+ const fallback = await this.resolve.defaultOffice(tx, workspaceId);
251
+ const defaultCovers = by === 'office' ? fallback?.id === id : !!fallback && fallback.legalEntityId === id;
252
+ if (defaultCovers) {
253
+ const rows = await tx.select({ id: people.id }).from(people).where(eq(people.workspaceId, workspaceId));
254
+ return rows.map((r) => r.id);
255
+ }
256
+ const ids = new Set();
257
+ if (by === 'office') {
258
+ const rows = await tx
259
+ .select({ personId: officeAssignments.personId })
260
+ .from(officeAssignments)
261
+ .where(and(eq(officeAssignments.workspaceId, workspaceId), eq(officeAssignments.officeId, id), overlapsRange(officeAssignments.effectiveFrom, officeAssignments.effectiveTo)));
262
+ for (const row of rows)
263
+ ids.add(row.personId);
264
+ return [...ids];
265
+ }
266
+ const byEmployment = await tx
267
+ .select({ personId: employments.personId })
268
+ .from(employments)
269
+ .where(and(eq(employments.workspaceId, workspaceId), eq(employments.legalEntityId, id), overlapsRange(employments.effectiveFrom, employments.effectiveTo)));
270
+ for (const row of byEmployment)
271
+ ids.add(row.personId);
272
+ const entityOffices = await tx
273
+ .select({ id: offices.id })
274
+ .from(offices)
275
+ .where(and(eq(offices.workspaceId, workspaceId), eq(offices.legalEntityId, id)));
276
+ if (entityOffices.length) {
277
+ const byOffice = await tx
278
+ .select({ personId: officeAssignments.personId })
279
+ .from(officeAssignments)
280
+ .where(and(eq(officeAssignments.workspaceId, workspaceId), inArray(officeAssignments.officeId, entityOffices.map((o) => o.id)), overlapsRange(officeAssignments.effectiveFrom, officeAssignments.effectiveTo)));
281
+ for (const row of byOffice)
282
+ ids.add(row.personId);
283
+ }
284
+ return [...ids];
285
+ }
286
+ /**
287
+ * The ladder, once per date.
288
+ *
289
+ * The cost is one ladder walk per day in the range, which is why the per-day reports are capped at
290
+ * a quarter. The fix is a range-aware batch inside `ResolveService` — one walk that answers a span
291
+ * — and it belongs there rather than here, because a second implementation of "what applies to a
292
+ * person on a date" is the drift that service exists to prevent.
293
+ */
294
+ async resolveByDate(tx, workspaceId, personIds, dates) {
295
+ const out = new Map();
296
+ if (!personIds.length || !dates.length) {
297
+ for (const date of dates)
298
+ out.set(date, new Map());
299
+ return out;
300
+ }
301
+ // Refused with both numbers in it rather than left to run for minutes. The refusal names the
302
+ // two things a reader can act on — how many people, over how many days — because which of them
303
+ // to shrink is their decision and not this module's.
304
+ const refusal = rangeRefusal({
305
+ from: dates[0],
306
+ to: dates[dates.length - 1],
307
+ perDay: true,
308
+ population: personIds.length,
309
+ });
310
+ if (refusal)
311
+ throw KernError.badRequest(refusal);
312
+ for (const date of dates)
313
+ out.set(date, await this.resolve.forPeople(tx, workspaceId, personIds, date));
314
+ return out;
315
+ }
316
+ async sliceName(tx, workspaceId, by, id) {
317
+ if (by === 'office') {
318
+ const [row] = await tx
319
+ .select({ name: offices.name })
320
+ .from(offices)
321
+ .where(and(eq(offices.workspaceId, workspaceId), eq(offices.id, id)))
322
+ .limit(1);
323
+ return row?.name ?? null;
324
+ }
325
+ const [row] = await tx
326
+ .select({ name: legalEntities.name })
327
+ .from(legalEntities)
328
+ .where(and(eq(legalEntities.workspaceId, workspaceId), eq(legalEntities.id, id)))
329
+ .limit(1);
330
+ return row?.name ?? null;
331
+ }
332
+ /**
333
+ * Turn a per-day membership into the fewest queries that can express it.
334
+ *
335
+ * People who belonged to the slice on the same set of days share one group, so a report nobody
336
+ * transferred through — which is nearly all of them — is a single aggregate over the whole range
337
+ * rather than one per day.
338
+ */
339
+ groupsFor(population, from, to) {
340
+ if (!population.datesByPerson)
341
+ return [{ personIds: null, dates: null }];
342
+ const whole = datesBetween(from, to).join(',');
343
+ const bySignature = new Map();
344
+ for (const [personId, dates] of population.datesByPerson) {
345
+ const signature = dates.join(',');
346
+ const existing = bySignature.get(signature);
347
+ if (existing)
348
+ existing.personIds?.push(personId);
349
+ else
350
+ bySignature.set(signature, { personIds: [personId], dates: signature === whole ? null : dates });
351
+ }
352
+ return [...bySignature.values()];
353
+ }
354
+ /**
355
+ * Every figure the attendance and overtime reports need, per person, in one grouped aggregate.
356
+ *
357
+ * `attendance_days` only — never `punches`. Punches are raw, append-only and partitioned, and a
358
+ * voided punch survives beside the correction that replaced it, so summing them double-counts
359
+ * every fix that has ever been made. The day sheet is the projection those punches produce and the
360
+ * only thing that has already applied the schedule, the calendar and the rounding.
361
+ *
362
+ * `beyond_cap_minutes` is summed **without** `coalesce`: Postgres answers NULL when every row in
363
+ * the group is null, which is exactly "no annual ceiling was in force on any of these days".
364
+ */
365
+ async dayAggregate(tx, workspaceId, group, from, to) {
366
+ const where = [eq(attendanceDays.workspaceId, workspaceId)];
367
+ if (group.dates === null) {
368
+ where.push(gte(attendanceDays.businessDate, from), lte(attendanceDays.businessDate, to));
369
+ }
370
+ else {
371
+ if (!group.dates.length)
372
+ return [];
373
+ where.push(inArray(attendanceDays.businessDate, group.dates));
374
+ }
375
+ if (group.personIds !== null) {
376
+ if (!group.personIds.length)
377
+ return [];
378
+ where.push(inArray(attendanceDays.personId, group.personIds));
379
+ }
380
+ const rows = await tx
381
+ .select({
382
+ personId: attendanceDays.personId,
383
+ days: sql `count(*)`,
384
+ scheduledMinutes: sql `coalesce(sum(${attendanceDays.scheduledMinutes}), 0)`,
385
+ workedMinutes: sql `coalesce(sum(${attendanceDays.workedMinutes}), 0)`,
386
+ breakMinutes: sql `coalesce(sum(${attendanceDays.breakMinutes}), 0)`,
387
+ lateMinutes: sql `coalesce(sum(${attendanceDays.lateMinutes}), 0)`,
388
+ earlyLeaveMinutes: sql `coalesce(sum(${attendanceDays.earlyLeaveMinutes}), 0)`,
389
+ overtimeMinutes: sql `coalesce(sum(${attendanceDays.overtimeMinutes}), 0)`,
390
+ // No coalesce. NULL here is the answer, not a missing one.
391
+ beyondCapMinutes: sql `sum(${attendanceDays.beyondCapMinutes})`,
392
+ cappedDays: sql `count(${attendanceDays.beyondCapMinutes})`,
393
+ uncappedDays: sql `count(*) filter (where ${attendanceDays.beyondCapMinutes} is null)`,
394
+ noScheduleDays: sql `count(*) filter (where ${attendanceDays.policyHash} like 'none:%')`,
395
+ unknownScheduleDays: sql `count(*) filter (where ${attendanceDays.policyHash} is null)`,
396
+ lockedDays: sql `count(*) filter (where ${attendanceDays.locked})`,
397
+ openDays: sql `count(*) filter (where not ${attendanceDays.locked})`,
398
+ firstOpenDay: sql `(min(${attendanceDays.businessDate}) filter (where not ${attendanceDays.locked}))::text`,
399
+ lastLockedDay: sql `(max(${attendanceDays.businessDate}) filter (where ${attendanceDays.locked}))::text`,
400
+ })
401
+ .from(attendanceDays)
402
+ .where(and(...where))
403
+ .groupBy(attendanceDays.personId);
404
+ return rows.map((r) => ({
405
+ personId: r.personId,
406
+ days: int(r.days),
407
+ scheduledMinutes: int(r.scheduledMinutes),
408
+ workedMinutes: int(r.workedMinutes),
409
+ breakMinutes: int(r.breakMinutes),
410
+ lateMinutes: int(r.lateMinutes),
411
+ earlyLeaveMinutes: int(r.earlyLeaveMinutes),
412
+ overtimeMinutes: int(r.overtimeMinutes),
413
+ beyondCapMinutes: r.beyondCapMinutes === null ? null : int(r.beyondCapMinutes),
414
+ cappedDays: int(r.cappedDays),
415
+ uncappedDays: int(r.uncappedDays),
416
+ noScheduleDays: int(r.noScheduleDays),
417
+ unknownScheduleDays: int(r.unknownScheduleDays),
418
+ lockedDays: int(r.lockedDays),
419
+ openDays: int(r.openDays),
420
+ firstOpenDay: r.firstOpenDay ?? null,
421
+ lastLockedDay: r.lastLockedDay ?? null,
422
+ }));
423
+ }
424
+ /**
425
+ * Expected, worked and excused days for one set of people who share one expectation.
426
+ *
427
+ * The expectation arrives as parallel `(date, fraction)` arrays — at most one range long — because
428
+ * `workingDays()` in `policy/calendar.ts` is the only implementation of working-day arithmetic
429
+ * this module has, and rewriting it as `generate_series` plus a weekday case would be a second one
430
+ * that drifts. Everything else stays in the database: the join to the day sheets, the join to
431
+ * approved leave and the sums are all Postgres's, so a quarter of a large office is one query
432
+ * rather than tens of thousands of rows crossing the wire.
433
+ *
434
+ * A day is expected only where a **schedule assignment is in force**, which is the module's own
435
+ * rule for whether somebody owes hours at all. It is what keeps a joiner's first fortnight, a
436
+ * leaver's last, and every salaried colleague who never clocks in out of the denominator without
437
+ * this file reading a hire date.
438
+ */
439
+ async absenceAggregate(tx, workspaceId, personIds, expected, countLeave) {
440
+ const worked = expected.filter((e) => e.fraction > 0);
441
+ if (!personIds.length || !worked.length)
442
+ return [];
443
+ const dates = worked.map((e) => e.date);
444
+ const fractions = worked.map((e) => e.fraction);
445
+ const scheduled = sql `
446
+ (select distinct sa.person_id as person_id, e.d as d, e.f as f
447
+ from unnest(${dates}::date[], ${fractions}::numeric[]) as e(d, f)
448
+ join ${scheduleAssignments} sa
449
+ on sa.workspace_id = ${workspaceId}
450
+ and sa.person_id = any(${personIds}::uuid[])
451
+ and sa.effective_from <= e.d
452
+ and (sa.effective_to is null or sa.effective_to >= e.d)) s`;
453
+ // Left-joined rather than filtered, so a day with no sheet at all still counts towards the
454
+ // expectation — that missing row is the whole point of the report and is ambiguous between
455
+ // "not scheduled", "on approved leave" and "absent" until these joins have spoken.
456
+ const sheets = sql `
457
+ left join ${attendanceDays} ad
458
+ on ad.workspace_id = ${workspaceId} and ad.person_id = s.person_id and ad.business_date = s.d`;
459
+ const leave = countLeave
460
+ ? sql `
461
+ left join mod_hr.leave_request_days lrd
462
+ on lrd.workspace_id = ${workspaceId} and lrd.person_id = s.person_id and lrd.date = s.d
463
+ and lrd.counted and lrd.status = 'approved'`
464
+ : sql ``;
465
+ const rows = await tx
466
+ .select({
467
+ personId: sql `s.person_id::text`,
468
+ expectedDays: sql `coalesce(sum(s.f), 0)::float8`,
469
+ workedDays: sql `coalesce(sum(s.f) filter (where coalesce(ad.worked_minutes, 0) > 0), 0)::float8`,
470
+ leaveDays: countLeave
471
+ ? sql `coalesce(sum(least(s.f, lrd.fraction)) filter (where coalesce(ad.worked_minutes, 0) = 0 and lrd.id is not null), 0)::float8`
472
+ : sql `0::float8`,
473
+ lockedDays: sql `count(ad.id) filter (where ad.locked)`,
474
+ openDays: sql `count(ad.id) filter (where not ad.locked)`,
475
+ firstOpenDay: sql `(min(ad.business_date) filter (where not ad.locked))::text`,
476
+ lastLockedDay: sql `(max(ad.business_date) filter (where ad.locked))::text`,
477
+ })
478
+ .from(sql `${scheduled}${sheets}${leave}`)
479
+ .groupBy(sql `s.person_id`);
480
+ return rows.map((r) => ({
481
+ personId: r.personId,
482
+ expectedDays: round2(Number(r.expectedDays ?? 0)),
483
+ workedDays: round2(Number(r.workedDays ?? 0)),
484
+ leaveDays: countLeave ? round2(Number(r.leaveDays ?? 0)) : null,
485
+ lockedDays: int(r.lockedDays),
486
+ openDays: int(r.openDays),
487
+ firstOpenDay: r.firstOpenDay ?? null,
488
+ lastLockedDay: r.lastLockedDay ?? null,
489
+ }));
490
+ }
491
+ /** Who has any schedule assignment overlapping the range, so "no schedule" is a fact rather than a guess. */
492
+ async scheduledPeople(tx, workspaceId, personIds, from, to) {
493
+ if (!personIds.length)
494
+ return new Set();
495
+ const rows = await tx
496
+ .select({ personId: scheduleAssignments.personId })
497
+ .from(scheduleAssignments)
498
+ .where(and(eq(scheduleAssignments.workspaceId, workspaceId), sql `${scheduleAssignments.personId} = any(${personIds}::uuid[])`, lte(scheduleAssignments.effectiveFrom, to), or(isNull(scheduleAssignments.effectiveTo), gte(scheduleAssignments.effectiveTo, from))));
499
+ return new Set(rows.map((r) => r.personId));
500
+ }
501
+ /**
502
+ * Every balance in the population, per leave type, in three queries rather than three per person.
503
+ *
504
+ * The single-person path runs three queries per person, which over five hundred people is fifteen
505
+ * hundred round trips — the shape `ResolveService.forPeople` and `PolicyService.forPeople` were
506
+ * both rescued from. This is the batched twin and it belongs in `LedgerService` the next time that
507
+ * file is opened.
508
+ *
509
+ * It differs from the single-person path in one figure, deliberately. That one joins
510
+ * `leave_request_days` to `leave_requests` and sums `lr.minutes`, so a request contributes its
511
+ * whole minute total **once per day it covers** — a five-day request counts five times, and
512
+ * `available = balance − pending` inherits it. Summing over `leave_requests` directly is both
513
+ * correct and simpler, and a report that goes into a payroll conversation is the wrong place to
514
+ * reproduce an off-by-a-factor.
515
+ */
516
+ async leaveBalances(tx, workspaceId, personIds, periodYear) {
517
+ if (!personIds.length)
518
+ return [];
519
+ const types = await tx
520
+ .select({ id: leaveTypes.id, name: leaveTypes.name, unit: leaveTypes.unit, order: leaveTypes.order })
521
+ .from(leaveTypes)
522
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), isNull(leaveTypes.archivedAt)));
523
+ if (!types.length)
524
+ return [];
525
+ const typeById = new Map(types.map((t) => [t.id, t]));
526
+ const sums = await tx
527
+ .select({
528
+ personId: leaveLedger.personId,
529
+ leaveTypeId: leaveLedger.leaveTypeId,
530
+ total: sql `coalesce(sum(${leaveLedger.amountMinutes}), 0)`,
531
+ })
532
+ .from(leaveLedger)
533
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.periodYear, periodYear), sql `${leaveLedger.personId} = any(${personIds}::uuid[])`))
534
+ .groupBy(leaveLedger.personId, leaveLedger.leaveTypeId);
535
+ const live = await tx
536
+ .select({
537
+ personId: leaveRequests.personId,
538
+ leaveTypeId: leaveRequests.leaveTypeId,
539
+ status: leaveRequests.status,
540
+ minutes: sql `coalesce(sum(${leaveRequests.minutes}), 0)`,
541
+ })
542
+ .from(leaveRequests)
543
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), sql `${leaveRequests.personId} = any(${personIds}::uuid[])`, inArray(leaveRequests.status, ['pending', 'approved'])))
544
+ .groupBy(leaveRequests.personId, leaveRequests.leaveTypeId, leaveRequests.status);
545
+ const key = (personId, typeId) => `${personId}:${typeId}`;
546
+ const seen = new Map();
547
+ const balance = new Map();
548
+ const booked = new Map();
549
+ const pending = new Map();
550
+ for (const row of sums) {
551
+ if (!typeById.has(row.leaveTypeId))
552
+ continue;
553
+ const k = key(row.personId, row.leaveTypeId);
554
+ seen.set(k, { personId: row.personId, leaveTypeId: row.leaveTypeId });
555
+ balance.set(k, int(row.total));
556
+ }
557
+ for (const row of live) {
558
+ if (!typeById.has(row.leaveTypeId))
559
+ continue;
560
+ const k = key(row.personId, row.leaveTypeId);
561
+ seen.set(k, { personId: row.personId, leaveTypeId: row.leaveTypeId });
562
+ const target = row.status === 'approved' ? booked : pending;
563
+ target.set(k, (target.get(k) ?? 0) + int(row.minutes));
564
+ }
565
+ return [...seen.entries()].map(([k, ref]) => {
566
+ const type = typeById.get(ref.leaveTypeId);
567
+ return {
568
+ personId: ref.personId,
569
+ leaveTypeId: ref.leaveTypeId,
570
+ leaveTypeName: type.name,
571
+ unit: type.unit,
572
+ order: type.order,
573
+ ...balanceRow({
574
+ balanceMinutes: balance.get(k) ?? 0,
575
+ bookedMinutes: booked.get(k) ?? 0,
576
+ pendingMinutes: pending.get(k) ?? 0,
577
+ unit: type.unit,
578
+ }),
579
+ };
580
+ });
581
+ }
582
+ /**
583
+ * Display names for the rows a report is about to return.
584
+ *
585
+ * Asked after the rows are chosen rather than for the whole population, and shown exactly as
586
+ * stored: an erased person keeps their row and carries the erasure token in `display_name`.
587
+ * Dropping the row would change the total, and printing "Unknown" would hide that this is a person
588
+ * the workspace deliberately redacted.
589
+ */
590
+ async namesOf(tx, workspaceId, personIds) {
591
+ if (!personIds.length)
592
+ return new Map();
593
+ const rows = await tx
594
+ .select({ id: people.id, displayName: people.displayName })
595
+ .from(people)
596
+ .where(and(eq(people.workspaceId, workspaceId), sql `${people.id} = any(${personIds}::uuid[])`));
597
+ return new Map(rows.map((r) => [r.id, r.displayName]));
598
+ }
599
+ }
600
+ /**
601
+ * One person's expected working days, per date, from the ladder's answer for each date.
602
+ *
603
+ * Pure but for its inputs, and kept beside the reports rather than in `policy/calendar.ts` because
604
+ * it is a *report's* composition of two things that already exist — the ladder's resolution for a
605
+ * date, and `workingDays()` for the calendar behind it. The calendar is asked per date because a
606
+ * person who changes office mid-month changes calendar mid-month, and averaging the two would be an
607
+ * invented number.
608
+ */
609
+ export function expectedDaysFor(dates, resolutionFor, calendarDaysFor) {
610
+ const expected = [];
611
+ let hasCalendar = true;
612
+ for (const date of dates) {
613
+ const resolution = resolutionFor(date);
614
+ if (!resolution) {
615
+ // Not in the population on this date — no expectation, and not evidence about the calendar.
616
+ continue;
617
+ }
618
+ if (resolution.calendarId === null)
619
+ hasCalendar = false;
620
+ const week = resolution.workingWeek;
621
+ const days = resolution.calendarId ? calendarDaysFor(resolution.calendarId) : [];
622
+ const [result] = workingDays(date, date, week, days);
623
+ expected.push({ date, fraction: result?.fraction ?? 0 });
624
+ }
625
+ return { expected, hasCalendar };
626
+ }
627
+ //# sourceMappingURL=reports.js.map