@kernhq/module-hr 0.17.1 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +10 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/permissions.d.ts +8 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +25 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/reports.d.ts +505 -0
- package/dist/contract/reports.d.ts.map +1 -0
- package/dist/contract/reports.js +328 -0
- package/dist/contract/reports.js.map +1 -0
- package/dist/contract/router.d.ts +422 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +97 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +488 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +439 -17
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +27 -3
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/reports.d.ts +321 -0
- package/dist/server/services/reports.d.ts.map +1 -0
- package/dist/server/services/reports.js +634 -0
- package/dist/server/services/reports.js.map +1 -0
- package/package.json +1 -1
- package/src/contract/capabilities.ts +10 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/permissions.ts +27 -0
- package/src/contract/reports.ts +373 -0
- package/src/contract/router.ts +110 -0
package/dist/server/router.js
CHANGED
|
@@ -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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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,358 @@ 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
|
+
scheduledWorkedMinutes: sum(rows, (r) => r.scheduledWorkedMinutes),
|
|
3233
|
+
breakMinutes: sum(rows, (r) => r.breakMinutes),
|
|
3234
|
+
lateMinutes: sum(rows, (r) => r.lateMinutes),
|
|
3235
|
+
earlyLeaveMinutes: sum(rows, (r) => r.earlyLeaveMinutes),
|
|
3236
|
+
noScheduleDays: sum(rows, (r) => r.noScheduleDays),
|
|
3237
|
+
unknownScheduleDays: sum(rows, (r) => r.unknownScheduleDays),
|
|
3238
|
+
};
|
|
3239
|
+
const shown = [...rows]
|
|
3240
|
+
.sort((a, b) => b.workedMinutes - a.workedMinutes || a.personId.localeCompare(b.personId))
|
|
3241
|
+
.slice(0, input.limit);
|
|
3242
|
+
const names = await reports.namesOf(tx, input.workspaceId, shown.map((r) => r.personId));
|
|
3243
|
+
return {
|
|
3244
|
+
header: reportHeader({
|
|
3245
|
+
input,
|
|
3246
|
+
slice,
|
|
3247
|
+
population,
|
|
3248
|
+
counted: rows.length,
|
|
3249
|
+
shown: shown.length,
|
|
3250
|
+
permissions: ['hr.report.view', 'hr.attendance.view_team'],
|
|
3251
|
+
}),
|
|
3252
|
+
finality: mergeFinality(rows),
|
|
3253
|
+
totals: {
|
|
3254
|
+
...totals,
|
|
3255
|
+
workedRatio: ratio(totals.scheduledWorkedMinutes, totals.scheduledMinutes),
|
|
3256
|
+
},
|
|
3257
|
+
rows: shown.map((r) => ({
|
|
3258
|
+
personId: r.personId,
|
|
3259
|
+
displayName: names.get(r.personId) ?? '',
|
|
3260
|
+
days: r.days,
|
|
3261
|
+
scheduledMinutes: r.scheduledMinutes,
|
|
3262
|
+
workedMinutes: r.workedMinutes,
|
|
3263
|
+
scheduledWorkedMinutes: r.scheduledWorkedMinutes,
|
|
3264
|
+
breakMinutes: r.breakMinutes,
|
|
3265
|
+
lateMinutes: r.lateMinutes,
|
|
3266
|
+
earlyLeaveMinutes: r.earlyLeaveMinutes,
|
|
3267
|
+
workedRatio: ratio(r.scheduledWorkedMinutes, r.scheduledMinutes),
|
|
3268
|
+
noScheduleDays: r.noScheduleDays,
|
|
3269
|
+
unknownScheduleDays: r.unknownScheduleDays,
|
|
3270
|
+
})),
|
|
3271
|
+
};
|
|
3272
|
+
})),
|
|
3273
|
+
/**
|
|
3274
|
+
* Overtime, and how much of it an annual ceiling would not take.
|
|
3275
|
+
*
|
|
3276
|
+
* `beyondCapMinutes` is summed as a nullable column and never coalesced: Postgres answers NULL
|
|
3277
|
+
* when every day in the group is null, which is exactly "no ceiling was in force on any of
|
|
3278
|
+
* these days" — a different fact from "one applied and nothing passed it". `cappedDays` is
|
|
3279
|
+
* beside it so a reader can see which of the two they are looking at.
|
|
3280
|
+
*/
|
|
3281
|
+
overtime: scoped.reports.overtime
|
|
3282
|
+
.use(cap('attendance'))
|
|
3283
|
+
.use(requires('hr.report.view'))
|
|
3284
|
+
.use(requires('hr.attendance.view_team'))
|
|
3285
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
3286
|
+
const { slice, population, rows } = await dayReport(tx, input);
|
|
3287
|
+
const shown = [...rows]
|
|
3288
|
+
.sort((a, b) => b.overtimeMinutes - a.overtimeMinutes || a.personId.localeCompare(b.personId))
|
|
3289
|
+
.slice(0, input.limit);
|
|
3290
|
+
const names = await reports.namesOf(tx, input.workspaceId, shown.map((r) => r.personId));
|
|
3291
|
+
return {
|
|
3292
|
+
header: reportHeader({
|
|
3293
|
+
input,
|
|
3294
|
+
slice,
|
|
3295
|
+
population,
|
|
3296
|
+
counted: rows.length,
|
|
3297
|
+
shown: shown.length,
|
|
3298
|
+
permissions: ['hr.report.view', 'hr.attendance.view_team'],
|
|
3299
|
+
}),
|
|
3300
|
+
finality: mergeFinality(rows),
|
|
3301
|
+
totals: {
|
|
3302
|
+
days: sum(rows, (r) => r.days),
|
|
3303
|
+
overtimeMinutes: sum(rows, (r) => r.overtimeMinutes),
|
|
3304
|
+
// The days are added up; the minutes are not, unless at least one day had a ceiling.
|
|
3305
|
+
beyondCapMinutes: capTotal(rows.map((r) => r.beyondCapMinutes)).beyondCapMinutes,
|
|
3306
|
+
cappedDays: sum(rows, (r) => r.cappedDays),
|
|
3307
|
+
uncappedDays: sum(rows, (r) => r.uncappedDays),
|
|
3308
|
+
},
|
|
3309
|
+
rows: shown.map((r) => ({
|
|
3310
|
+
personId: r.personId,
|
|
3311
|
+
displayName: names.get(r.personId) ?? '',
|
|
3312
|
+
days: r.days,
|
|
3313
|
+
overtimeMinutes: r.overtimeMinutes,
|
|
3314
|
+
beyondCapMinutes: r.beyondCapMinutes,
|
|
3315
|
+
cappedDays: r.cappedDays,
|
|
3316
|
+
uncappedDays: r.uncappedDays,
|
|
3317
|
+
})),
|
|
3318
|
+
};
|
|
3319
|
+
})),
|
|
3320
|
+
/**
|
|
3321
|
+
* Expected working days, minus days worked, minus approved leave.
|
|
3322
|
+
*
|
|
3323
|
+
* **Never `status = 'absent'`.** `attendance_days` holds a row only where somebody punched —
|
|
3324
|
+
* the punch path, a regularization, auto-clock-out and the two recompute jobs are the only
|
|
3325
|
+
* writers, and not one of them creates a row for a day nobody clocked in on. Counting absent
|
|
3326
|
+
* rows therefore reports near-zero absence in every workspace and looks entirely healthy while
|
|
3327
|
+
* doing it. The denominator is built from the calendar instead, and the two populations it
|
|
3328
|
+
* cannot answer for are named rather than dropped: somebody with no schedule assignment owes
|
|
3329
|
+
* no hours, and an office with no calendar attached would only be measured against an assumed
|
|
3330
|
+
* Monday–Friday week.
|
|
3331
|
+
*/
|
|
3332
|
+
absence: scoped.reports.absence
|
|
3333
|
+
.use(cap('attendance'))
|
|
3334
|
+
.use(requires('hr.report.view'))
|
|
3335
|
+
.use(requires('hr.attendance.view_team'))
|
|
3336
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
3337
|
+
const slice = sliceOf(input);
|
|
3338
|
+
// Always a per-day report, sliced or not: every person's expectation is their own
|
|
3339
|
+
// office's calendar on each day, which is a ladder walk per day either way.
|
|
3340
|
+
const refusal = rangeRefusal({ from: input.from, to: input.to, perDay: true });
|
|
3341
|
+
if (refusal)
|
|
3342
|
+
throw KernError.badRequest(refusal);
|
|
3343
|
+
const population = await reports.population(tx, input.workspaceId, slice, input.from, input.to);
|
|
3344
|
+
const dates = datesBetween(input.from, input.to);
|
|
3345
|
+
const resolutions = population.resolutions ??
|
|
3346
|
+
(await reports.resolveByDate(tx, input.workspaceId, population.personIds, dates));
|
|
3347
|
+
const calendarIds = new Set();
|
|
3348
|
+
for (const perDate of resolutions.values())
|
|
3349
|
+
for (const resolution of perDate.values())
|
|
3350
|
+
if (resolution.calendarId)
|
|
3351
|
+
calendarIds.add(resolution.calendarId);
|
|
3352
|
+
// Composed once per calendar rather than once per person: the holidays are a property
|
|
3353
|
+
// of the office, and a workspace has a handful of calendars behind however many people.
|
|
3354
|
+
const calendarDaysById = new Map();
|
|
3355
|
+
for (const calendarId of calendarIds)
|
|
3356
|
+
calendarDaysById.set(calendarId, (await composedDays(tx, input.workspaceId, calendarId, input.from, input.to)).map((d) => ({
|
|
3357
|
+
date: d.date,
|
|
3358
|
+
name: d.name,
|
|
3359
|
+
workingFraction: d.workingFraction,
|
|
3360
|
+
})));
|
|
3361
|
+
const scheduled = await reports.scheduledPeople(tx, input.workspaceId, population.personIds, input.from, input.to);
|
|
3362
|
+
// `leave` is a capability of its own, and a workspace that has it off holds no approved
|
|
3363
|
+
// leave to subtract. The column is then absent rather than zero, and `leaveCounted` says
|
|
3364
|
+
// so — a zero would read as "nobody was away", which is a claim rather than a silence.
|
|
3365
|
+
const leaveCounted = (await kernel.capabilities(input.workspaceId, MODULE_ID)).has('leave');
|
|
3366
|
+
const basisByPerson = new Map();
|
|
3367
|
+
const groups = new Map();
|
|
3368
|
+
for (const personId of population.personIds) {
|
|
3369
|
+
const mine = population.datesByPerson?.get(personId) ?? dates;
|
|
3370
|
+
const { expected, hasCalendar } = expectedDaysFor(mine, (date) => resolutions.get(date)?.get(personId), (calendarId) => calendarDaysById.get(calendarId) ?? []);
|
|
3371
|
+
const basis = absenceBasis({ hasSchedule: scheduled.has(personId), hasCalendar });
|
|
3372
|
+
basisByPerson.set(personId, basis);
|
|
3373
|
+
if (basis !== 'calendar')
|
|
3374
|
+
continue;
|
|
3375
|
+
const signature = expected.map((e) => `${e.date}:${e.fraction}`).join(',');
|
|
3376
|
+
const existing = groups.get(signature);
|
|
3377
|
+
if (existing)
|
|
3378
|
+
existing.personIds.push(personId);
|
|
3379
|
+
else
|
|
3380
|
+
groups.set(signature, { personIds: [personId], expected });
|
|
3381
|
+
}
|
|
3382
|
+
const measured = [];
|
|
3383
|
+
for (const group of groups.values())
|
|
3384
|
+
measured.push(...(await reports.absenceAggregate(tx, input.workspaceId, group.personIds, group.expected, leaveCounted)));
|
|
3385
|
+
const byPerson = new Map(measured.map((r) => [r.personId, r]));
|
|
3386
|
+
const rows = [...basisByPerson].map(([personId, basis]) => {
|
|
3387
|
+
const found = byPerson.get(personId);
|
|
3388
|
+
if (basis !== 'calendar' || !found)
|
|
3389
|
+
return {
|
|
3390
|
+
personId,
|
|
3391
|
+
basis,
|
|
3392
|
+
expectedDays: null,
|
|
3393
|
+
workedDays: null,
|
|
3394
|
+
leaveDays: null,
|
|
3395
|
+
absentDays: null,
|
|
3396
|
+
absenceRate: null,
|
|
3397
|
+
};
|
|
3398
|
+
const split = absenceSplit(found);
|
|
3399
|
+
return {
|
|
3400
|
+
personId,
|
|
3401
|
+
basis,
|
|
3402
|
+
expectedDays: found.expectedDays,
|
|
3403
|
+
workedDays: found.workedDays,
|
|
3404
|
+
leaveDays: found.leaveDays,
|
|
3405
|
+
absentDays: split.absentDays,
|
|
3406
|
+
absenceRate: split.absenceRate,
|
|
3407
|
+
};
|
|
3408
|
+
});
|
|
3409
|
+
const totalExpected = sum(measured, (r) => r.expectedDays);
|
|
3410
|
+
const totalWorked = sum(measured, (r) => r.workedDays);
|
|
3411
|
+
const totalLeave = leaveCounted ? sum(measured, (r) => r.leaveDays ?? 0) : null;
|
|
3412
|
+
const totalSplit = absenceSplit({
|
|
3413
|
+
expectedDays: totalExpected,
|
|
3414
|
+
workedDays: totalWorked,
|
|
3415
|
+
leaveDays: totalLeave,
|
|
3416
|
+
});
|
|
3417
|
+
// Measured people first, ordered by what a reader came for; the two named buckets after
|
|
3418
|
+
// them, so they are visible rather than truncated away by the row limit.
|
|
3419
|
+
const shown = rows
|
|
3420
|
+
.sort((a, b) => (a.basis === 'calendar' ? 0 : 1) - (b.basis === 'calendar' ? 0 : 1) ||
|
|
3421
|
+
(b.absentDays ?? -1) - (a.absentDays ?? -1) ||
|
|
3422
|
+
a.personId.localeCompare(b.personId))
|
|
3423
|
+
.slice(0, input.limit);
|
|
3424
|
+
const names = await reports.namesOf(tx, input.workspaceId, shown.map((r) => r.personId));
|
|
3425
|
+
return {
|
|
3426
|
+
header: reportHeader({
|
|
3427
|
+
input,
|
|
3428
|
+
slice: { ...slice, name: population.sliceName },
|
|
3429
|
+
population: population.personIds.length,
|
|
3430
|
+
counted: measured.length,
|
|
3431
|
+
shown: shown.length,
|
|
3432
|
+
permissions: ['hr.report.view', 'hr.attendance.view_team'],
|
|
3433
|
+
attribution: 'each_day',
|
|
3434
|
+
}),
|
|
3435
|
+
finality: mergeFinality(measured),
|
|
3436
|
+
leaveCounted,
|
|
3437
|
+
totals: {
|
|
3438
|
+
measured: measured.length,
|
|
3439
|
+
expectedDays: round2(totalExpected),
|
|
3440
|
+
workedDays: round2(totalWorked),
|
|
3441
|
+
leaveDays: totalLeave === null ? null : round2(totalLeave),
|
|
3442
|
+
absentDays: totalSplit.absentDays,
|
|
3443
|
+
absenceRate: totalSplit.absenceRate,
|
|
3444
|
+
},
|
|
3445
|
+
excluded: {
|
|
3446
|
+
noSchedule: rows.filter((r) => r.basis === 'no_schedule').length,
|
|
3447
|
+
noCalendar: rows.filter((r) => r.basis === 'no_calendar').length,
|
|
3448
|
+
},
|
|
3449
|
+
rows: shown.map((r) => ({ ...r, displayName: names.get(r.personId) ?? '' })),
|
|
3450
|
+
};
|
|
3451
|
+
})),
|
|
3452
|
+
/**
|
|
3453
|
+
* Every balance in the population, per leave type, for one entitlement year.
|
|
3454
|
+
*
|
|
3455
|
+
* Summed from `leave_ledger` and nothing else — the cursor exists to be locked, and a cache
|
|
3456
|
+
* that is also the source of truth eventually disagrees with it. There is no entitlement, no
|
|
3457
|
+
* allowance remaining and no year-end projection: all three need an accrual policy, the
|
|
3458
|
+
* `leave_accrual` capability ships off, and a company that grants a fixed allowance on 1
|
|
3459
|
+
* January has a perfectly real balance and no policy at all. `dayLengthMinutes` is published
|
|
3460
|
+
* because `toUnit` renders a `day` at a constant eight hours, and a report that prints days
|
|
3461
|
+
* without saying which day is the quiet way this goes wrong.
|
|
3462
|
+
*/
|
|
3463
|
+
leaveBalance: scoped.reports.leaveBalance
|
|
3464
|
+
.use(cap('leave'))
|
|
3465
|
+
.use(requires('hr.report.view'))
|
|
3466
|
+
.use(requires('hr.leave.view_team'))
|
|
3467
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
3468
|
+
const asOf = input.asOf ?? todayIso();
|
|
3469
|
+
const periodYear = input.periodYear ?? yearOf(asOf);
|
|
3470
|
+
const slice = sliceOf(input);
|
|
3471
|
+
// A balance is a position rather than a per-day quantity, so it is attributed as of one
|
|
3472
|
+
// date and the header says which. Resolving it per day would be arithmetic nobody asked
|
|
3473
|
+
// for on a figure that does not vary by day.
|
|
3474
|
+
const population = await reports.population(tx, input.workspaceId, slice, asOf, asOf);
|
|
3475
|
+
const all = await reports.leaveBalances(tx, input.workspaceId, population.personIds, periodYear);
|
|
3476
|
+
const counted = [...new Set(all.map((r) => r.personId))];
|
|
3477
|
+
const names = await reports.namesOf(tx, input.workspaceId, counted);
|
|
3478
|
+
const shownPeople = counted
|
|
3479
|
+
.sort((a, b) => (names.get(a) ?? '').localeCompare(names.get(b) ?? '') || a.localeCompare(b))
|
|
3480
|
+
.slice(0, input.limit);
|
|
3481
|
+
const keep = new Set(shownPeople);
|
|
3482
|
+
const totals = new Map();
|
|
3483
|
+
for (const row of all) {
|
|
3484
|
+
const found = totals.get(row.leaveTypeId);
|
|
3485
|
+
if (found) {
|
|
3486
|
+
found.balanceMinutes += row.balanceMinutes;
|
|
3487
|
+
found.bookedMinutes += row.bookedMinutes;
|
|
3488
|
+
found.pendingMinutes += row.pendingMinutes;
|
|
3489
|
+
found.availableMinutes += row.availableMinutes;
|
|
3490
|
+
found.people += 1;
|
|
3491
|
+
}
|
|
3492
|
+
else
|
|
3493
|
+
totals.set(row.leaveTypeId, { ...row, people: 1 });
|
|
3494
|
+
}
|
|
3495
|
+
return {
|
|
3496
|
+
header: reportHeader({
|
|
3497
|
+
input: { from: asOf, to: asOf, by: input.by },
|
|
3498
|
+
slice: { ...slice, name: population.sliceName },
|
|
3499
|
+
population: population.personIds.length,
|
|
3500
|
+
counted: counted.length,
|
|
3501
|
+
shown: shownPeople.length,
|
|
3502
|
+
permissions: ['hr.report.view', 'hr.leave.view_team'],
|
|
3503
|
+
attribution: 'as_of_date',
|
|
3504
|
+
attributionOn: asOf,
|
|
3505
|
+
}),
|
|
3506
|
+
periodYear,
|
|
3507
|
+
dayLengthMinutes: MINUTES_PER_DAY,
|
|
3508
|
+
totals: [...totals.values()]
|
|
3509
|
+
.sort((a, b) => a.order - b.order || a.leaveTypeName.localeCompare(b.leaveTypeName))
|
|
3510
|
+
.map((t) => ({
|
|
3511
|
+
leaveTypeId: t.leaveTypeId,
|
|
3512
|
+
leaveTypeName: t.leaveTypeName,
|
|
3513
|
+
unit: t.unit,
|
|
3514
|
+
people: t.people,
|
|
3515
|
+
balanceMinutes: t.balanceMinutes,
|
|
3516
|
+
bookedMinutes: t.bookedMinutes,
|
|
3517
|
+
pendingMinutes: t.pendingMinutes,
|
|
3518
|
+
availableMinutes: t.availableMinutes,
|
|
3519
|
+
})),
|
|
3520
|
+
rows: all
|
|
3521
|
+
.filter((r) => keep.has(r.personId))
|
|
3522
|
+
.sort((a, b) => (names.get(a.personId) ?? '').localeCompare(names.get(b.personId) ?? '') ||
|
|
3523
|
+
a.order - b.order ||
|
|
3524
|
+
a.leaveTypeName.localeCompare(b.leaveTypeName))
|
|
3525
|
+
.map((r) => ({
|
|
3526
|
+
personId: r.personId,
|
|
3527
|
+
displayName: names.get(r.personId) ?? '',
|
|
3528
|
+
leaveTypeId: r.leaveTypeId,
|
|
3529
|
+
leaveTypeName: r.leaveTypeName,
|
|
3530
|
+
unit: r.unit,
|
|
3531
|
+
balanceMinutes: r.balanceMinutes,
|
|
3532
|
+
bookedMinutes: r.bookedMinutes,
|
|
3533
|
+
pendingMinutes: r.pendingMinutes,
|
|
3534
|
+
availableMinutes: r.availableMinutes,
|
|
3535
|
+
balance: r.balance,
|
|
3536
|
+
available: r.available,
|
|
3537
|
+
})),
|
|
3538
|
+
};
|
|
3539
|
+
})),
|
|
3540
|
+
},
|
|
3186
3541
|
// ================================================================= privacy
|
|
3187
3542
|
/**
|
|
3188
3543
|
* Subject access, erasure and retention.
|
|
@@ -3681,6 +4036,73 @@ export function implement_(kernel) {
|
|
|
3681
4036
|
});
|
|
3682
4037
|
return personId;
|
|
3683
4038
|
}
|
|
4039
|
+
// ------------------------------------------------------------------ reports
|
|
4040
|
+
/** What the caller asked to narrow to, refusing a slice with nothing to narrow *to*. */
|
|
4041
|
+
function sliceOf(input) {
|
|
4042
|
+
if (input.by === 'workspace')
|
|
4043
|
+
return { by: 'workspace', id: null, name: null };
|
|
4044
|
+
if (!input.sliceId)
|
|
4045
|
+
throw KernError.badRequest('A report sliced by an office or a legal entity needs the id of one.');
|
|
4046
|
+
return { by: input.by, id: input.sliceId, name: null };
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
4049
|
+
* A declaration rather than a `const`, like every other helper down here.
|
|
4050
|
+
*
|
|
4051
|
+
* This whole block sits *after* `return os.router(…)`, so a `const` is never evaluated and every
|
|
4052
|
+
* handler that reached for it would throw a `ReferenceError` the first time somebody opened a
|
|
4053
|
+
* report — at runtime only, with a clean type-check and a clean test run behind it. Function
|
|
4054
|
+
* declarations hoist; that is the only reason the helpers below one already-returned statement
|
|
4055
|
+
* work at all.
|
|
4056
|
+
*/
|
|
4057
|
+
function sum(rows, of) {
|
|
4058
|
+
return rows.reduce((total, row) => total + of(row), 0);
|
|
4059
|
+
}
|
|
4060
|
+
/**
|
|
4061
|
+
* The header every report carries, and the reason it is not optional.
|
|
4062
|
+
*
|
|
4063
|
+
* A total with no denominator beside it is the defect, not the scoping: "47 hours of overtime"
|
|
4064
|
+
* means one thing to somebody reading a whole company and another to somebody reading a team, and
|
|
4065
|
+
* neither of them is told which they have. So the population, the range, the slice, the
|
|
4066
|
+
* attribution rule and the keys that produced it all travel with the figures.
|
|
4067
|
+
*/
|
|
4068
|
+
function reportHeader(args) {
|
|
4069
|
+
return {
|
|
4070
|
+
from: args.input.from,
|
|
4071
|
+
to: args.input.to,
|
|
4072
|
+
slice: args.slice,
|
|
4073
|
+
scope: { permissions: args.permissions, askedAt: 'workspace' },
|
|
4074
|
+
population: args.population,
|
|
4075
|
+
counted: args.counted,
|
|
4076
|
+
attribution: args.attribution ??
|
|
4077
|
+
(args.input.by === 'workspace' ? 'not_applicable' : 'each_day'),
|
|
4078
|
+
attributionOn: args.attributionOn ?? null,
|
|
4079
|
+
truncated: args.shown < args.counted,
|
|
4080
|
+
};
|
|
4081
|
+
}
|
|
4082
|
+
/**
|
|
4083
|
+
* The population and the per-person day-sheet aggregate the attendance and overtime reports share.
|
|
4084
|
+
*
|
|
4085
|
+
* One grouped aggregate per set of people who belonged to the slice on the same days — which for a
|
|
4086
|
+
* range nobody transferred through is one query over the whole range. The rows are one per person,
|
|
4087
|
+
* never one per person-day: a year of five hundred people is a hundred and thirty thousand day
|
|
4088
|
+
* sheets, and adding them up in this process is the shape that works in a demo and falls over on
|
|
4089
|
+
* the first real customer.
|
|
4090
|
+
*/
|
|
4091
|
+
async function dayReport(tx, input) {
|
|
4092
|
+
const slice = sliceOf(input);
|
|
4093
|
+
const refusal = rangeRefusal({ from: input.from, to: input.to, perDay: slice.by !== 'workspace' });
|
|
4094
|
+
if (refusal)
|
|
4095
|
+
throw KernError.badRequest(refusal);
|
|
4096
|
+
const population = await reports.population(tx, input.workspaceId, slice, input.from, input.to);
|
|
4097
|
+
const rows = [];
|
|
4098
|
+
for (const group of reports.groupsFor(population, input.from, input.to))
|
|
4099
|
+
rows.push(...(await reports.dayAggregate(tx, input.workspaceId, group, input.from, input.to)));
|
|
4100
|
+
return {
|
|
4101
|
+
slice: { ...slice, name: population.sliceName },
|
|
4102
|
+
population: population.personIds.length,
|
|
4103
|
+
rows,
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
3684
4106
|
/** The request a retry is a retry *of*, or undefined the first time a key is seen. */
|
|
3685
4107
|
async function byIdempotencyKey(tx, workspaceId, key) {
|
|
3686
4108
|
const [row] = await tx
|