@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.
@@ -1,10 +1,10 @@
1
1
  import { KernError, packageVersion, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
2
2
  import { implement } from '@orpc/server';
3
3
  import { and, asc, count, desc, eq, getTableColumns, gte, ilike, inArray, isNull, lte, or, sql, } from 'drizzle-orm';
4
- import { HrSettings, hrContract, hrEvents, MODULE_ID } from '../contract/index.js';
4
+ import { HrSettings, hrContract, hrEvents, MODULE_ID, } from '../contract/index.js';
5
5
  import { AccrualConfig, CarryForwardConfig, OvertimeConfig, RoundingConfig, WorkingTimeConfig, } from '../contract/policies.js';
6
6
  import { accrueForPeriod } from '../policy/accrual.js';
7
- import { countWorkingDays, workingDays } from '../policy/calendar.js';
7
+ import { countWorkingDays, datesBetween, workingDays } from '../policy/calendar.js';
8
8
  import { COUNTRY_PACKS, packDays, packFor } from './packs/index.js';
9
9
  import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, attendanceDays, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, periods, personDocuments, personHistory, policies, policyAssignments, positions, punches, regularizations, scheduleAssignments, schedules, } from './schema.js';
10
10
  import { forViewer, HrAccessService, seesRecordOf, visibleSet } from './services/access.js';
@@ -16,6 +16,7 @@ import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
16
16
  import { PeopleService } from './services/people.js';
17
17
  import { hashConfig, PolicyService } from './services/policies.js';
18
18
  import { closingBalance, PrivacyService, RETENTION_CLASSES, stripSensitiveCustom, } from './services/privacy.js';
19
+ import { absenceBasis, absenceSplit, capTotal, expectedDaysFor, mergeFinality, ReportsService, rangeRefusal, ratio, round2, } from './services/reports.js';
19
20
  import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
20
21
  const os = implement(hrContract).$context();
21
22
  /** Shared so the ordinary case — no sensitive custom fields defined — allocates nothing per page. */
@@ -120,7 +121,7 @@ async function calendarChain(tx, workspaceId, calendarId) {
120
121
  * `hrSubjects` below reach it, so the leave calculation a deadline runs reads the same calendar as
121
122
  * the one a person runs.
122
123
  */
123
- async function composedDays(tx, workspaceId, calendarId, from, to) {
124
+ export async function composedDays(tx, workspaceId, calendarId, from, to) {
124
125
  const chain = await calendarChain(tx, workspaceId, calendarId);
125
126
  const rows = await tx
126
127
  .select()
@@ -444,6 +445,7 @@ export function implement_(kernel) {
444
445
  */
445
446
  const approvals = new ApprovalService(kernel, subjects.appliersFor(null));
446
447
  const privacy = new PrivacyService();
448
+ const reports = new ReportsService(resolve);
447
449
  const audit = new HrAuditService(kernel, access);
448
450
  const db = kernel.database;
449
451
  const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
@@ -721,20 +723,21 @@ export function implement_(kernel) {
721
723
  };
722
724
  })),
723
725
  sensitive: {
724
- get: scoped.people.sensitive.get.use(requires('hr.person.view_sensitive')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
725
- const [row] = await tx
726
- .select()
727
- .from(peopleSensitive)
728
- .where(and(eq(peopleSensitive.workspaceId, input.workspaceId), eq(peopleSensitive.personId, input.personId)))
729
- .limit(1);
730
- return {
731
- personId: input.personId,
732
- workspaceId: input.workspaceId,
733
- nationalId: row?.nationalIdEnc ? await kernel.secrets.decrypt(row.nationalIdEnc) : null,
734
- birthDate: row?.birthDate ?? null,
735
- iban: row?.ibanEnc ? await kernel.secrets.decrypt(row.ibanEnc) : null,
736
- emergencyContact: row?.emergencyContact ?? null,
737
- };
726
+ /**
727
+ * Through `readSensitive`, not inline — this is the whole point of that method.
728
+ *
729
+ * It decrypts and writes the `sensitive_access_log` row in one transaction, so the read
730
+ * cannot happen without being recorded. Decrypting here instead left the log recording
731
+ * *exports* and nothing else: the one procedure that exists to read these fields was the
732
+ * one procedure not logging them, and a subject-access answer built on that would have
733
+ * stated in writing that nobody had opened her bank details.
734
+ */
735
+ get: scoped.people.sensitive.get
736
+ .use(requires('hr.person.view_sensitive'))
737
+ .handler(({ input, context }) => svc.readSensitive({
738
+ workspaceId: input.workspaceId,
739
+ personId: input.personId,
740
+ principal: context.principal,
738
741
  })),
739
742
  update: scoped.people.sensitive.update
740
743
  .use(requires('hr.person.manage_sensitive'))
@@ -3183,6 +3186,356 @@ export function implement_(kernel) {
3183
3186
  return { ok: true };
3184
3187
  }),
3185
3188
  },
3189
+ // ================================================================= reports
3190
+ /**
3191
+ * Four aggregates, and the two rules that decide every line of them.
3192
+ *
3193
+ * **A report is a separate grant, and it says which one produced it.** Each of these costs
3194
+ * `hr.report.view` *and* the key that already guards the rows it sums — `hr.attendance.view_team`
3195
+ * is what reading a whole office's day sheets costs on `attendance.days.list`, and
3196
+ * `hr.leave.view_team` is what reading somebody else's balance costs through `personFor`. So a
3197
+ * report reaches no further than the rows a reader could already page through, and it does not
3198
+ * narrow below them either: the population is never intersected with
3199
+ * `HrAccessService.visiblePersonIds`, which withholds *fields* nothing here reads and would
3200
+ * otherwise leave two managers reading different totals under one title. A reader holding
3201
+ * neither key gets nothing at all rather than a one-row self-report.
3202
+ *
3203
+ * Both `view_team` keys are declared `scope: 'object'` and are asked here at **workspace** scope,
3204
+ * which is what `requires()` does and what every existing caller does explicitly. Binding one to
3205
+ * an office id narrows nothing today — `Authz.can(object, id)` falls through to the
3206
+ * workspace-level effective set — so the response says `askedAt: 'workspace'` rather than
3207
+ * letting a reader infer a scoping that is not happening.
3208
+ *
3209
+ * **Nothing here writes.** No recompute, no refresh: `recomputeDay` is the only thing entitled to
3210
+ * decide whether a day may move, and a report over a filed month must not be able to move it.
3211
+ */
3212
+ reports: {
3213
+ /**
3214
+ * Scheduled against worked, per person.
3215
+ *
3216
+ * `workedRatio` is null wherever nothing was scheduled. That is not a rare edge: somebody with
3217
+ * no schedule assignment resolves to `NO_SCHEDULE`, owes no hours, and is written down as
3218
+ * `present` with `scheduledMinutes: 0` — so a percentage would be a division by zero dressed
3219
+ * up as 0% or 100%. `noScheduleDays` counts them from the policy stamp, which is the only
3220
+ * positive evidence; `scheduledMinutes === 0` is also true of a scheduled rest day.
3221
+ */
3222
+ attendance: scoped.reports.attendance
3223
+ .use(cap('attendance'))
3224
+ .use(requires('hr.report.view'))
3225
+ .use(requires('hr.attendance.view_team'))
3226
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
3227
+ const { slice, population, rows } = await dayReport(tx, input);
3228
+ const totals = {
3229
+ days: sum(rows, (r) => r.days),
3230
+ scheduledMinutes: sum(rows, (r) => r.scheduledMinutes),
3231
+ workedMinutes: sum(rows, (r) => r.workedMinutes),
3232
+ breakMinutes: sum(rows, (r) => r.breakMinutes),
3233
+ lateMinutes: sum(rows, (r) => r.lateMinutes),
3234
+ earlyLeaveMinutes: sum(rows, (r) => r.earlyLeaveMinutes),
3235
+ noScheduleDays: sum(rows, (r) => r.noScheduleDays),
3236
+ unknownScheduleDays: sum(rows, (r) => r.unknownScheduleDays),
3237
+ };
3238
+ const shown = [...rows]
3239
+ .sort((a, b) => b.workedMinutes - a.workedMinutes || a.personId.localeCompare(b.personId))
3240
+ .slice(0, input.limit);
3241
+ const names = await reports.namesOf(tx, input.workspaceId, shown.map((r) => r.personId));
3242
+ return {
3243
+ header: reportHeader({
3244
+ input,
3245
+ slice,
3246
+ population,
3247
+ counted: rows.length,
3248
+ shown: shown.length,
3249
+ permissions: ['hr.report.view', 'hr.attendance.view_team'],
3250
+ }),
3251
+ finality: mergeFinality(rows),
3252
+ totals: {
3253
+ ...totals,
3254
+ workedRatio: ratio(totals.workedMinutes, totals.scheduledMinutes),
3255
+ },
3256
+ rows: shown.map((r) => ({
3257
+ personId: r.personId,
3258
+ displayName: names.get(r.personId) ?? '',
3259
+ days: r.days,
3260
+ scheduledMinutes: r.scheduledMinutes,
3261
+ workedMinutes: r.workedMinutes,
3262
+ breakMinutes: r.breakMinutes,
3263
+ lateMinutes: r.lateMinutes,
3264
+ earlyLeaveMinutes: r.earlyLeaveMinutes,
3265
+ workedRatio: ratio(r.workedMinutes, r.scheduledMinutes),
3266
+ noScheduleDays: r.noScheduleDays,
3267
+ unknownScheduleDays: r.unknownScheduleDays,
3268
+ })),
3269
+ };
3270
+ })),
3271
+ /**
3272
+ * Overtime, and how much of it an annual ceiling would not take.
3273
+ *
3274
+ * `beyondCapMinutes` is summed as a nullable column and never coalesced: Postgres answers NULL
3275
+ * when every day in the group is null, which is exactly "no ceiling was in force on any of
3276
+ * these days" — a different fact from "one applied and nothing passed it". `cappedDays` is
3277
+ * beside it so a reader can see which of the two they are looking at.
3278
+ */
3279
+ overtime: scoped.reports.overtime
3280
+ .use(cap('attendance'))
3281
+ .use(requires('hr.report.view'))
3282
+ .use(requires('hr.attendance.view_team'))
3283
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
3284
+ const { slice, population, rows } = await dayReport(tx, input);
3285
+ const shown = [...rows]
3286
+ .sort((a, b) => b.overtimeMinutes - a.overtimeMinutes || a.personId.localeCompare(b.personId))
3287
+ .slice(0, input.limit);
3288
+ const names = await reports.namesOf(tx, input.workspaceId, shown.map((r) => r.personId));
3289
+ return {
3290
+ header: reportHeader({
3291
+ input,
3292
+ slice,
3293
+ population,
3294
+ counted: rows.length,
3295
+ shown: shown.length,
3296
+ permissions: ['hr.report.view', 'hr.attendance.view_team'],
3297
+ }),
3298
+ finality: mergeFinality(rows),
3299
+ totals: {
3300
+ days: sum(rows, (r) => r.days),
3301
+ overtimeMinutes: sum(rows, (r) => r.overtimeMinutes),
3302
+ // The days are added up; the minutes are not, unless at least one day had a ceiling.
3303
+ beyondCapMinutes: capTotal(rows.map((r) => r.beyondCapMinutes)).beyondCapMinutes,
3304
+ cappedDays: sum(rows, (r) => r.cappedDays),
3305
+ uncappedDays: sum(rows, (r) => r.uncappedDays),
3306
+ },
3307
+ rows: shown.map((r) => ({
3308
+ personId: r.personId,
3309
+ displayName: names.get(r.personId) ?? '',
3310
+ days: r.days,
3311
+ overtimeMinutes: r.overtimeMinutes,
3312
+ beyondCapMinutes: r.beyondCapMinutes,
3313
+ cappedDays: r.cappedDays,
3314
+ uncappedDays: r.uncappedDays,
3315
+ })),
3316
+ };
3317
+ })),
3318
+ /**
3319
+ * Expected working days, minus days worked, minus approved leave.
3320
+ *
3321
+ * **Never `status = 'absent'`.** `attendance_days` holds a row only where somebody punched —
3322
+ * the punch path, a regularization, auto-clock-out and the two recompute jobs are the only
3323
+ * writers, and not one of them creates a row for a day nobody clocked in on. Counting absent
3324
+ * rows therefore reports near-zero absence in every workspace and looks entirely healthy while
3325
+ * doing it. The denominator is built from the calendar instead, and the two populations it
3326
+ * cannot answer for are named rather than dropped: somebody with no schedule assignment owes
3327
+ * no hours, and an office with no calendar attached would only be measured against an assumed
3328
+ * Monday–Friday week.
3329
+ */
3330
+ absence: scoped.reports.absence
3331
+ .use(cap('attendance'))
3332
+ .use(requires('hr.report.view'))
3333
+ .use(requires('hr.attendance.view_team'))
3334
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
3335
+ const slice = sliceOf(input);
3336
+ // Always a per-day report, sliced or not: every person's expectation is their own
3337
+ // office's calendar on each day, which is a ladder walk per day either way.
3338
+ const refusal = rangeRefusal({ from: input.from, to: input.to, perDay: true });
3339
+ if (refusal)
3340
+ throw KernError.badRequest(refusal);
3341
+ const population = await reports.population(tx, input.workspaceId, slice, input.from, input.to);
3342
+ const dates = datesBetween(input.from, input.to);
3343
+ const resolutions = population.resolutions ??
3344
+ (await reports.resolveByDate(tx, input.workspaceId, population.personIds, dates));
3345
+ const calendarIds = new Set();
3346
+ for (const perDate of resolutions.values())
3347
+ for (const resolution of perDate.values())
3348
+ if (resolution.calendarId)
3349
+ calendarIds.add(resolution.calendarId);
3350
+ // Composed once per calendar rather than once per person: the holidays are a property
3351
+ // of the office, and a workspace has a handful of calendars behind however many people.
3352
+ const calendarDaysById = new Map();
3353
+ for (const calendarId of calendarIds)
3354
+ calendarDaysById.set(calendarId, (await composedDays(tx, input.workspaceId, calendarId, input.from, input.to)).map((d) => ({
3355
+ date: d.date,
3356
+ name: d.name,
3357
+ workingFraction: d.workingFraction,
3358
+ })));
3359
+ const scheduled = await reports.scheduledPeople(tx, input.workspaceId, population.personIds, input.from, input.to);
3360
+ // `leave` is a capability of its own, and a workspace that has it off holds no approved
3361
+ // leave to subtract. The column is then absent rather than zero, and `leaveCounted` says
3362
+ // so — a zero would read as "nobody was away", which is a claim rather than a silence.
3363
+ const leaveCounted = (await kernel.capabilities(input.workspaceId, MODULE_ID)).has('leave');
3364
+ const basisByPerson = new Map();
3365
+ const groups = new Map();
3366
+ for (const personId of population.personIds) {
3367
+ const mine = population.datesByPerson?.get(personId) ?? dates;
3368
+ const { expected, hasCalendar } = expectedDaysFor(mine, (date) => resolutions.get(date)?.get(personId), (calendarId) => calendarDaysById.get(calendarId) ?? []);
3369
+ const basis = absenceBasis({ hasSchedule: scheduled.has(personId), hasCalendar });
3370
+ basisByPerson.set(personId, basis);
3371
+ if (basis !== 'calendar')
3372
+ continue;
3373
+ const signature = expected.map((e) => `${e.date}:${e.fraction}`).join(',');
3374
+ const existing = groups.get(signature);
3375
+ if (existing)
3376
+ existing.personIds.push(personId);
3377
+ else
3378
+ groups.set(signature, { personIds: [personId], expected });
3379
+ }
3380
+ const measured = [];
3381
+ for (const group of groups.values())
3382
+ measured.push(...(await reports.absenceAggregate(tx, input.workspaceId, group.personIds, group.expected, leaveCounted)));
3383
+ const byPerson = new Map(measured.map((r) => [r.personId, r]));
3384
+ const rows = [...basisByPerson].map(([personId, basis]) => {
3385
+ const found = byPerson.get(personId);
3386
+ if (basis !== 'calendar' || !found)
3387
+ return {
3388
+ personId,
3389
+ basis,
3390
+ expectedDays: null,
3391
+ workedDays: null,
3392
+ leaveDays: null,
3393
+ absentDays: null,
3394
+ absenceRate: null,
3395
+ };
3396
+ const split = absenceSplit(found);
3397
+ return {
3398
+ personId,
3399
+ basis,
3400
+ expectedDays: found.expectedDays,
3401
+ workedDays: found.workedDays,
3402
+ leaveDays: found.leaveDays,
3403
+ absentDays: split.absentDays,
3404
+ absenceRate: split.absenceRate,
3405
+ };
3406
+ });
3407
+ const totalExpected = sum(measured, (r) => r.expectedDays);
3408
+ const totalWorked = sum(measured, (r) => r.workedDays);
3409
+ const totalLeave = leaveCounted ? sum(measured, (r) => r.leaveDays ?? 0) : null;
3410
+ const totalSplit = absenceSplit({
3411
+ expectedDays: totalExpected,
3412
+ workedDays: totalWorked,
3413
+ leaveDays: totalLeave,
3414
+ });
3415
+ // Measured people first, ordered by what a reader came for; the two named buckets after
3416
+ // them, so they are visible rather than truncated away by the row limit.
3417
+ const shown = rows
3418
+ .sort((a, b) => (a.basis === 'calendar' ? 0 : 1) - (b.basis === 'calendar' ? 0 : 1) ||
3419
+ (b.absentDays ?? -1) - (a.absentDays ?? -1) ||
3420
+ a.personId.localeCompare(b.personId))
3421
+ .slice(0, input.limit);
3422
+ const names = await reports.namesOf(tx, input.workspaceId, shown.map((r) => r.personId));
3423
+ return {
3424
+ header: reportHeader({
3425
+ input,
3426
+ slice: { ...slice, name: population.sliceName },
3427
+ population: population.personIds.length,
3428
+ counted: measured.length,
3429
+ shown: shown.length,
3430
+ permissions: ['hr.report.view', 'hr.attendance.view_team'],
3431
+ attribution: 'each_day',
3432
+ }),
3433
+ finality: mergeFinality(measured),
3434
+ leaveCounted,
3435
+ totals: {
3436
+ measured: measured.length,
3437
+ expectedDays: round2(totalExpected),
3438
+ workedDays: round2(totalWorked),
3439
+ leaveDays: totalLeave === null ? null : round2(totalLeave),
3440
+ absentDays: totalSplit.absentDays,
3441
+ absenceRate: totalSplit.absenceRate,
3442
+ },
3443
+ excluded: {
3444
+ noSchedule: rows.filter((r) => r.basis === 'no_schedule').length,
3445
+ noCalendar: rows.filter((r) => r.basis === 'no_calendar').length,
3446
+ },
3447
+ rows: shown.map((r) => ({ ...r, displayName: names.get(r.personId) ?? '' })),
3448
+ };
3449
+ })),
3450
+ /**
3451
+ * Every balance in the population, per leave type, for one entitlement year.
3452
+ *
3453
+ * Summed from `leave_ledger` and nothing else — the cursor exists to be locked, and a cache
3454
+ * that is also the source of truth eventually disagrees with it. There is no entitlement, no
3455
+ * allowance remaining and no year-end projection: all three need an accrual policy, the
3456
+ * `leave_accrual` capability ships off, and a company that grants a fixed allowance on 1
3457
+ * January has a perfectly real balance and no policy at all. `dayLengthMinutes` is published
3458
+ * because `toUnit` renders a `day` at a constant eight hours, and a report that prints days
3459
+ * without saying which day is the quiet way this goes wrong.
3460
+ */
3461
+ leaveBalance: scoped.reports.leaveBalance
3462
+ .use(cap('leave'))
3463
+ .use(requires('hr.report.view'))
3464
+ .use(requires('hr.leave.view_team'))
3465
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
3466
+ const asOf = input.asOf ?? todayIso();
3467
+ const periodYear = input.periodYear ?? yearOf(asOf);
3468
+ const slice = sliceOf(input);
3469
+ // A balance is a position rather than a per-day quantity, so it is attributed as of one
3470
+ // date and the header says which. Resolving it per day would be arithmetic nobody asked
3471
+ // for on a figure that does not vary by day.
3472
+ const population = await reports.population(tx, input.workspaceId, slice, asOf, asOf);
3473
+ const all = await reports.leaveBalances(tx, input.workspaceId, population.personIds, periodYear);
3474
+ const counted = [...new Set(all.map((r) => r.personId))];
3475
+ const names = await reports.namesOf(tx, input.workspaceId, counted);
3476
+ const shownPeople = counted
3477
+ .sort((a, b) => (names.get(a) ?? '').localeCompare(names.get(b) ?? '') || a.localeCompare(b))
3478
+ .slice(0, input.limit);
3479
+ const keep = new Set(shownPeople);
3480
+ const totals = new Map();
3481
+ for (const row of all) {
3482
+ const found = totals.get(row.leaveTypeId);
3483
+ if (found) {
3484
+ found.balanceMinutes += row.balanceMinutes;
3485
+ found.bookedMinutes += row.bookedMinutes;
3486
+ found.pendingMinutes += row.pendingMinutes;
3487
+ found.availableMinutes += row.availableMinutes;
3488
+ found.people += 1;
3489
+ }
3490
+ else
3491
+ totals.set(row.leaveTypeId, { ...row, people: 1 });
3492
+ }
3493
+ return {
3494
+ header: reportHeader({
3495
+ input: { from: asOf, to: asOf, by: input.by },
3496
+ slice: { ...slice, name: population.sliceName },
3497
+ population: population.personIds.length,
3498
+ counted: counted.length,
3499
+ shown: shownPeople.length,
3500
+ permissions: ['hr.report.view', 'hr.leave.view_team'],
3501
+ attribution: 'as_of_date',
3502
+ attributionOn: asOf,
3503
+ }),
3504
+ periodYear,
3505
+ dayLengthMinutes: MINUTES_PER_DAY,
3506
+ totals: [...totals.values()]
3507
+ .sort((a, b) => a.order - b.order || a.leaveTypeName.localeCompare(b.leaveTypeName))
3508
+ .map((t) => ({
3509
+ leaveTypeId: t.leaveTypeId,
3510
+ leaveTypeName: t.leaveTypeName,
3511
+ unit: t.unit,
3512
+ people: t.people,
3513
+ balanceMinutes: t.balanceMinutes,
3514
+ bookedMinutes: t.bookedMinutes,
3515
+ pendingMinutes: t.pendingMinutes,
3516
+ availableMinutes: t.availableMinutes,
3517
+ })),
3518
+ rows: all
3519
+ .filter((r) => keep.has(r.personId))
3520
+ .sort((a, b) => (names.get(a.personId) ?? '').localeCompare(names.get(b.personId) ?? '') ||
3521
+ a.order - b.order ||
3522
+ a.leaveTypeName.localeCompare(b.leaveTypeName))
3523
+ .map((r) => ({
3524
+ personId: r.personId,
3525
+ displayName: names.get(r.personId) ?? '',
3526
+ leaveTypeId: r.leaveTypeId,
3527
+ leaveTypeName: r.leaveTypeName,
3528
+ unit: r.unit,
3529
+ balanceMinutes: r.balanceMinutes,
3530
+ bookedMinutes: r.bookedMinutes,
3531
+ pendingMinutes: r.pendingMinutes,
3532
+ availableMinutes: r.availableMinutes,
3533
+ balance: r.balance,
3534
+ available: r.available,
3535
+ })),
3536
+ };
3537
+ })),
3538
+ },
3186
3539
  // ================================================================= privacy
3187
3540
  /**
3188
3541
  * Subject access, erasure and retention.
@@ -3681,6 +4034,73 @@ export function implement_(kernel) {
3681
4034
  });
3682
4035
  return personId;
3683
4036
  }
4037
+ // ------------------------------------------------------------------ reports
4038
+ /** What the caller asked to narrow to, refusing a slice with nothing to narrow *to*. */
4039
+ function sliceOf(input) {
4040
+ if (input.by === 'workspace')
4041
+ return { by: 'workspace', id: null, name: null };
4042
+ if (!input.sliceId)
4043
+ throw KernError.badRequest('A report sliced by an office or a legal entity needs the id of one.');
4044
+ return { by: input.by, id: input.sliceId, name: null };
4045
+ }
4046
+ /**
4047
+ * A declaration rather than a `const`, like every other helper down here.
4048
+ *
4049
+ * This whole block sits *after* `return os.router(…)`, so a `const` is never evaluated and every
4050
+ * handler that reached for it would throw a `ReferenceError` the first time somebody opened a
4051
+ * report — at runtime only, with a clean type-check and a clean test run behind it. Function
4052
+ * declarations hoist; that is the only reason the helpers below one already-returned statement
4053
+ * work at all.
4054
+ */
4055
+ function sum(rows, of) {
4056
+ return rows.reduce((total, row) => total + of(row), 0);
4057
+ }
4058
+ /**
4059
+ * The header every report carries, and the reason it is not optional.
4060
+ *
4061
+ * A total with no denominator beside it is the defect, not the scoping: "47 hours of overtime"
4062
+ * means one thing to somebody reading a whole company and another to somebody reading a team, and
4063
+ * neither of them is told which they have. So the population, the range, the slice, the
4064
+ * attribution rule and the keys that produced it all travel with the figures.
4065
+ */
4066
+ function reportHeader(args) {
4067
+ return {
4068
+ from: args.input.from,
4069
+ to: args.input.to,
4070
+ slice: args.slice,
4071
+ scope: { permissions: args.permissions, askedAt: 'workspace' },
4072
+ population: args.population,
4073
+ counted: args.counted,
4074
+ attribution: args.attribution ??
4075
+ (args.input.by === 'workspace' ? 'not_applicable' : 'each_day'),
4076
+ attributionOn: args.attributionOn ?? null,
4077
+ truncated: args.shown < args.counted,
4078
+ };
4079
+ }
4080
+ /**
4081
+ * The population and the per-person day-sheet aggregate the attendance and overtime reports share.
4082
+ *
4083
+ * One grouped aggregate per set of people who belonged to the slice on the same days — which for a
4084
+ * range nobody transferred through is one query over the whole range. The rows are one per person,
4085
+ * never one per person-day: a year of five hundred people is a hundred and thirty thousand day
4086
+ * sheets, and adding them up in this process is the shape that works in a demo and falls over on
4087
+ * the first real customer.
4088
+ */
4089
+ async function dayReport(tx, input) {
4090
+ const slice = sliceOf(input);
4091
+ const refusal = rangeRefusal({ from: input.from, to: input.to, perDay: slice.by !== 'workspace' });
4092
+ if (refusal)
4093
+ throw KernError.badRequest(refusal);
4094
+ const population = await reports.population(tx, input.workspaceId, slice, input.from, input.to);
4095
+ const rows = [];
4096
+ for (const group of reports.groupsFor(population, input.from, input.to))
4097
+ rows.push(...(await reports.dayAggregate(tx, input.workspaceId, group, input.from, input.to)));
4098
+ return {
4099
+ slice: { ...slice, name: population.sliceName },
4100
+ population: population.personIds.length,
4101
+ rows,
4102
+ };
4103
+ }
3684
4104
  /** The request a retry is a retry *of*, or undefined the first time a key is seen. */
3685
4105
  async function byIdempotencyKey(tx, workspaceId, key) {
3686
4106
  const [row] = await tx