@kernhq/module-hr 0.17.1 → 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.
- 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 +501 -0
- package/dist/contract/reports.d.ts.map +1 -0
- package/dist/contract/reports.js +318 -0
- package/dist/contract/reports.js.map +1 -0
- package/dist/contract/router.d.ts +420 -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 +486 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +437 -17
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/reports.d.ts +319 -0
- package/dist/server/services/reports.d.ts.map +1 -0
- package/dist/server/services/reports.js +627 -0
- package/dist/server/services/reports.js.map +1 -0
- package/package.json +1 -1
- package/src/contract/index.ts +1 -0
- package/src/contract/permissions.ts +27 -0
- package/src/contract/reports.ts +363 -0
- package/src/contract/router.ts +110 -0
|
@@ -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
|