@kernhq/module-hr 0.18.0 → 0.19.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.
Files changed (46) hide show
  1. package/dist/contract/capabilities.d.ts.map +1 -1
  2. package/dist/contract/capabilities.js +26 -0
  3. package/dist/contract/capabilities.js.map +1 -1
  4. package/dist/contract/exports.d.ts +459 -0
  5. package/dist/contract/exports.d.ts.map +1 -0
  6. package/dist/contract/exports.js +331 -0
  7. package/dist/contract/exports.js.map +1 -0
  8. package/dist/contract/index.d.ts +1 -0
  9. package/dist/contract/index.d.ts.map +1 -1
  10. package/dist/contract/index.js +1 -0
  11. package/dist/contract/index.js.map +1 -1
  12. package/dist/contract/permissions.d.ts +8 -0
  13. package/dist/contract/permissions.d.ts.map +1 -1
  14. package/dist/contract/permissions.js +32 -3
  15. package/dist/contract/permissions.js.map +1 -1
  16. package/dist/contract/reports.d.ts +4 -0
  17. package/dist/contract/reports.d.ts.map +1 -1
  18. package/dist/contract/reports.js +11 -1
  19. package/dist/contract/reports.js.map +1 -1
  20. package/dist/contract/router.d.ts +274 -0
  21. package/dist/contract/router.d.ts.map +1 -1
  22. package/dist/contract/router.js +67 -0
  23. package/dist/contract/router.js.map +1 -1
  24. package/dist/server/router.d.ts +285 -0
  25. package/dist/server/router.d.ts.map +1 -1
  26. package/dist/server/router.js +120 -2
  27. package/dist/server/router.js.map +1 -1
  28. package/dist/server/services/exports.d.ts +321 -0
  29. package/dist/server/services/exports.d.ts.map +1 -0
  30. package/dist/server/services/exports.js +765 -0
  31. package/dist/server/services/exports.js.map +1 -0
  32. package/dist/server/services/ledger.d.ts.map +1 -1
  33. package/dist/server/services/ledger.js +27 -3
  34. package/dist/server/services/ledger.js.map +1 -1
  35. package/dist/server/services/reports.d.ts +2 -0
  36. package/dist/server/services/reports.d.ts.map +1 -1
  37. package/dist/server/services/reports.js +14 -7
  38. package/dist/server/services/reports.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/client/messages.ts +13 -0
  41. package/src/contract/capabilities.ts +26 -0
  42. package/src/contract/exports.ts +354 -0
  43. package/src/contract/index.ts +1 -0
  44. package/src/contract/permissions.ts +34 -3
  45. package/src/contract/reports.ts +11 -1
  46. package/src/contract/router.ts +73 -0
@@ -0,0 +1,765 @@
1
+ import { KernError } from '@kernhq/kernel';
2
+ import { and, asc, eq, gte, inArray, isNull, lte, or, sql } from 'drizzle-orm';
3
+ import { PAYROLL_EXPORT_CONTRACT, PAYROLL_HOURS_COLUMNS, PAYROLL_LEAVE_COLUMNS, } from '../../contract/exports.js';
4
+ import { costCenters, employments, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, people, periods, positions, } from '../schema.js';
5
+ import { num } from './db.js';
6
+ import { capTotal, mergeFinality, rangeRefusal, round2, } from './reports.js';
7
+ /**
8
+ * The payroll export: what it refuses, how it is spelled, and where each number comes from.
9
+ *
10
+ * Same shape as `reports.ts` next door and for the same reason. **Everything above the class is
11
+ * pure** — no `tx`, no clock, no kernel — because the decisions worth pinning here are decisions
12
+ * about *characters*, and a character is invisible in a query plan. A display name with a comma, a
13
+ * quote or a newline is the oldest bug in this format and the one a customer finds first; a
14
+ * `beyond_cap_minutes` written as `0` instead of an empty field is the one that causes a wrong
15
+ * payment. Neither throws, neither fails a type-check, and both look like a working file.
16
+ *
17
+ * **Everything below the class is aggregated in the database.** Day sheets and approved leave are
18
+ * grouped in Postgres, one query per set of people who belonged to the entity on the same days —
19
+ * which for a month nobody transferred through is one query for the whole entity. Nothing here reads
20
+ * a period of day sheets into the process to add them up.
21
+ *
22
+ * Kern does not compute pay. There is no rate, no gross, no net, no deduction and no currency amount
23
+ * below this line, and there must not be one above it either.
24
+ */
25
+ // ====================================================================== pure
26
+ /**
27
+ * UTF-8 byte order mark.
28
+ *
29
+ * Excel on Windows reads a BOM-less UTF-8 CSV as the system code page and mangles every Turkish and
30
+ * Persian name in it — and this module ships country packs for both. Frozen with the rest of the
31
+ * format: v1 has a BOM for as long as v1 is published.
32
+ */
33
+ export const CSV_BOM = '';
34
+ /** CRLF, per RFC 4180 and frozen with the rest of the format. */
35
+ export const CSV_EOL = '\r\n';
36
+ /**
37
+ * When a field has to be quoted: a comma, a quote, a line break, or leading/trailing whitespace.
38
+ *
39
+ * The first three are RFC 4180's rule and the reason this function exists — `Şirket, A.Ş.` in a
40
+ * display name silently shifts every column after it by one, and the row still parses, so nothing
41
+ * reports an error and the figures land under the wrong headings. The last is not required by the
42
+ * standard and is kept anyway, because a bureau's importer trimming " 001" to "001" is a different
43
+ * employee.
44
+ */
45
+ const NEEDS_QUOTING = /[",\r\n]|^\s|\s$/;
46
+ /**
47
+ * One CSV field, RFC 4180.
48
+ *
49
+ * **Null is an empty field, never `0` and never the word "null".** That distinction is the whole
50
+ * point of `beyond_cap_minutes`: empty means no statutory ceiling was in force, `0` means one was and
51
+ * nothing exceeded it, and a provider reading `0` for empty is the one place this export can cause a
52
+ * wrong payment.
53
+ *
54
+ * A value is never altered to defend a spreadsheet. Prefixing a leading `=` or `+` to stop Excel
55
+ * evaluating it is the usual advice, and it would make this file disagree with `people.display_name`
56
+ * — a payroll file that quietly edits the names in it is worse than one that renders a strange name
57
+ * strangely.
58
+ */
59
+ export function csvField(value) {
60
+ if (value === null || value === undefined)
61
+ return '';
62
+ return NEEDS_QUOTING.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
63
+ }
64
+ /** One CSV record, terminated. */
65
+ export function csvLine(fields) {
66
+ return `${fields.map(csvField).join(',')}${CSV_EOL}`;
67
+ }
68
+ /**
69
+ * A whole CSV: BOM, the column names, then the rows.
70
+ *
71
+ * **Row 1 is the column names and nothing else.** No version banner above them: a line like
72
+ * `kern-payroll-v1` in row 1 is read as the first column name by every importer that assumes the
73
+ * header is the first row — which is most of them and all of the spreadsheet ones — and every field
74
+ * then lands one row down. The version rides in the first *column* instead, where an importer sees
75
+ * it as data.
76
+ */
77
+ export function csvDocument(columns, rows) {
78
+ return CSV_BOM + csvLine(columns) + rows.map(csvLine).join('');
79
+ }
80
+ /** An integer, as text. */
81
+ export const fmtInt = (value) => String(Math.round(value));
82
+ /** An integer that may be unknown. Null stays null so `csvField` writes an empty field. */
83
+ export const fmtNullableInt = (value) => (value === null ? null : fmtInt(value));
84
+ /**
85
+ * Two decimal places, `.` separated, and never `-0.00`.
86
+ *
87
+ * `fte` and every leave-day figure are written this way, frozen: halves and quarters add up exactly
88
+ * at two places, and a locale-dependent separator would make one customer's file unreadable by
89
+ * another's importer.
90
+ */
91
+ export function fmtDecimal(value) {
92
+ const rounded = round2(value);
93
+ return (rounded === 0 ? 0 : rounded).toFixed(2);
94
+ }
95
+ /** `true` / `false`, spelled out. Not 1/0, which a spreadsheet turns into a number column. */
96
+ export const fmtBool = (value) => (value ? 'true' : 'false');
97
+ /**
98
+ * The handful of letters `NFD` cannot decompose, for the filename slug.
99
+ *
100
+ * Turkish `ı` is the one that matters here — it is a letter in its own right rather than an `i` with
101
+ * something added, so stripping combining marks leaves it untouched and it would become a hyphen.
102
+ * `Kırşehir` reading as `k-r-ehir` in a filename is the kind of detail a customer reads as
103
+ * carelessness.
104
+ */
105
+ const SLUG_LETTERS = {
106
+ ı: 'i',
107
+ İ: 'i',
108
+ ø: 'o',
109
+ Ø: 'o',
110
+ đ: 'd',
111
+ Đ: 'd',
112
+ ł: 'l',
113
+ Ł: 'l',
114
+ ß: 'ss',
115
+ æ: 'ae',
116
+ Æ: 'ae',
117
+ œ: 'oe',
118
+ Œ: 'oe',
119
+ þ: 'th',
120
+ ð: 'd',
121
+ };
122
+ /**
123
+ * A legal entity's name, safe for a filename on any filesystem a bureau might use.
124
+ *
125
+ * `Kern Türkiye A.Ş.` → `kern-turkiye-a-s`. Lossy on purpose: the exact name travels in the manifest
126
+ * and in every row of both CSVs, so the filename only has to be legible and stable.
127
+ */
128
+ export function entitySlug(name) {
129
+ const letters = [...name].map((ch) => SLUG_LETTERS[ch] ?? ch).join('');
130
+ const ascii = letters.normalize('NFD').replace(/\p{M}/gu, '');
131
+ const slug = ascii
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9]+/g, '-')
134
+ .replace(/^-+|-+$/g, '')
135
+ .slice(0, 48)
136
+ .replace(/-+$/, '');
137
+ // An entity named only in a script that transliterates to nothing still needs a filename.
138
+ return slug || 'entity';
139
+ }
140
+ /** The last day of `YYYY-MM`, so a whole calendar month can be recognised as one. */
141
+ function lastDayOf(year, month) {
142
+ const day = new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate();
143
+ return String(day).padStart(2, '0');
144
+ }
145
+ /**
146
+ * `2026-06` for a whole calendar month, `2026-06-01_2026-06-15` for anything else.
147
+ *
148
+ * A payroll period is nearly always a month and a bureau reads `2026-06` at a glance; a period that
149
+ * is not one says so rather than being rounded to the month it mostly falls in.
150
+ */
151
+ export function periodLabel(from, to) {
152
+ const [fromYear, fromMonth, fromDay] = from.split('-');
153
+ const [toYear, toMonth, toDay] = to.split('-');
154
+ if (!fromYear || !fromMonth || !toYear || !toMonth)
155
+ return `${from}_${to}`;
156
+ const wholeMonth = fromYear === toYear && fromMonth === toMonth && fromDay === '01' && toDay === lastDayOf(toYear, toMonth);
157
+ return wholeMonth ? `${fromYear}-${fromMonth}` : `${from}_${to}`;
158
+ }
159
+ /**
160
+ * `kern-payroll-v1_kern-turkiye-a-s_2026-06_hours.csv`, and `DRAFT` where it belongs.
161
+ *
162
+ * The filename is the only part of this export that survives being emailed to a bureau, dropped on a
163
+ * shared drive and opened by somebody who never saw the API, so the contract version leads it. A
164
+ * bureau that receives two files six months apart and opens both under one mapping is the failure
165
+ * this prevents, and it is a failure nobody sees an error for.
166
+ */
167
+ export function exportFilename(input) {
168
+ const extension = input.file === 'manifest' ? 'json' : 'csv';
169
+ const draft = input.draft ? 'DRAFT_' : '';
170
+ const slug = entitySlug(input.entityName);
171
+ return `${PAYROLL_EXPORT_CONTRACT}_${slug}_${periodLabel(input.from, input.to)}_${draft}${input.file}.${extension}`;
172
+ }
173
+ /**
174
+ * One `hours.csv` record, in `PAYROLL_HOURS_COLUMNS` order.
175
+ *
176
+ * The order is the file format. Nothing here may be reordered, renamed, added to or removed while v1
177
+ * is published — a positional importer shifts on an appended column, and the shift lands on
178
+ * `unpaid_leave_days`, which is a deduction.
179
+ */
180
+ export function hoursCsvRow(header, row) {
181
+ return [
182
+ PAYROLL_EXPORT_CONTRACT,
183
+ header.legalEntityId,
184
+ header.legalEntityName,
185
+ header.periodStart,
186
+ header.periodEnd,
187
+ row.personId,
188
+ row.employeeNo,
189
+ row.displayName,
190
+ row.employmentType,
191
+ fmtDecimal(row.fte),
192
+ row.contractHoursWeek === null ? null : fmtDecimal(row.contractHoursWeek),
193
+ row.costCenterCode,
194
+ row.positionTitle,
195
+ row.hiredOn,
196
+ row.terminatedOn,
197
+ fmtBool(row.employmentChangedInPeriod),
198
+ fmtInt(row.daySheets),
199
+ fmtInt(row.scheduledMinutes),
200
+ fmtInt(row.workedMinutes),
201
+ fmtInt(row.scheduledWorkedMinutes),
202
+ fmtInt(row.breakMinutes),
203
+ fmtInt(row.overtimeMinutes),
204
+ fmtInt(row.lateMinutes),
205
+ fmtInt(row.earlyLeaveMinutes),
206
+ // The empty field the whole class exists for. Never `fmtInt(row.beyondCapMinutes ?? 0)`.
207
+ fmtNullableInt(row.beyondCapMinutes),
208
+ fmtInt(row.cappedDays),
209
+ fmtInt(row.uncappedDays),
210
+ fmtInt(row.lockedDays),
211
+ fmtInt(row.openDays),
212
+ fmtDecimal(row.paidLeaveDays),
213
+ fmtDecimal(row.unpaidLeaveDays),
214
+ ];
215
+ }
216
+ /** One `leave.csv` record, in `PAYROLL_LEAVE_COLUMNS` order. Frozen exactly as above. */
217
+ export function leaveCsvRow(header, row) {
218
+ return [
219
+ PAYROLL_EXPORT_CONTRACT,
220
+ header.legalEntityId,
221
+ header.legalEntityName,
222
+ header.periodStart,
223
+ header.periodEnd,
224
+ row.personId,
225
+ row.employeeNo,
226
+ row.leaveTypeKey,
227
+ row.leaveTypeName,
228
+ fmtBool(row.paid),
229
+ row.unit,
230
+ fmtDecimal(row.days),
231
+ fmtInt(row.requests),
232
+ ];
233
+ }
234
+ /**
235
+ * Everything over the whole population, so a screen states a total before a file exists.
236
+ *
237
+ * `beyondCapMinutes` is totalled through `capTotal` rather than added up: a sum that coalesces null
238
+ * to zero would report "nothing exceeded the ceiling" for a workspace where no ceiling ever applied.
239
+ */
240
+ export function totalsOf(rows) {
241
+ const sum = (of) => rows.reduce((total, row) => total + of(row), 0);
242
+ return {
243
+ people: rows.length,
244
+ daySheets: sum((r) => r.daySheets),
245
+ scheduledMinutes: sum((r) => r.scheduledMinutes),
246
+ workedMinutes: sum((r) => r.workedMinutes),
247
+ scheduledWorkedMinutes: sum((r) => r.scheduledWorkedMinutes),
248
+ breakMinutes: sum((r) => r.breakMinutes),
249
+ overtimeMinutes: sum((r) => r.overtimeMinutes),
250
+ lateMinutes: sum((r) => r.lateMinutes),
251
+ earlyLeaveMinutes: sum((r) => r.earlyLeaveMinutes),
252
+ beyondCapMinutes: capTotal(rows.map((r) => r.beyondCapMinutes)).beyondCapMinutes,
253
+ cappedDays: sum((r) => r.cappedDays),
254
+ uncappedDays: sum((r) => r.uncappedDays),
255
+ lockedDays: sum((r) => r.lockedDays),
256
+ openDays: sum((r) => r.openDays),
257
+ paidLeaveDays: round2(sum((r) => r.paidLeaveDays)),
258
+ unpaidLeaveDays: round2(sum((r) => r.unpaidLeaveDays)),
259
+ };
260
+ }
261
+ /** At most five names, then a count. A refusal listing four hundred people is a refusal nobody reads. */
262
+ function namesFor(withoutEmployment) {
263
+ const shown = withoutEmployment.slice(0, 5).map((p) => p.displayName);
264
+ const rest = withoutEmployment.length - shown.length;
265
+ return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ');
266
+ }
267
+ /**
268
+ * Why this export will not be written — the whole list, so a screen shows all of it at once.
269
+ *
270
+ * **Refuse rather than guess** is the rule, and each of these is a case where the alternative is a
271
+ * row of zeros somebody pays from:
272
+ *
273
+ * - **An open period may still move.** `reconcile-days` runs at 02:30 over a fourteen-day window and
274
+ * rebuilds every day a period does not close, so the same export at 18:00 and at 09:00 the next
275
+ * morning can differ with nobody having touched anything. Somebody pays from the first file and
276
+ * reconciles against the second. `draft` is the one escape hatch, and it is a statement the caller
277
+ * makes which the file then repeats — never a toast, because the toast does not travel with the CSV.
278
+ * - **An entity with nobody in it** is a mistyped id or an entity nobody has been moved into yet. An
279
+ * empty file with a correct header looks like a month in which nobody worked.
280
+ * - **A person with no employment row** has no employment type, no FTE, no cost centre and no
281
+ * position — every field a provider picks a rate from, blank. They reach the population through
282
+ * their office rather than their employment, which is a real state the ladder allows and not one
283
+ * anything should be paid on.
284
+ */
285
+ export function exportRefusals(input) {
286
+ const refusals = [];
287
+ if (input.periodStatus === 'open' && !input.draft)
288
+ refusals.push({
289
+ code: 'hr.period.not_locked',
290
+ message: `${input.periodStart} to ${input.periodEnd} is still open for ${input.legalEntityName}. ` +
291
+ 'Lock the period before exporting, or export a draft.',
292
+ personIds: [],
293
+ });
294
+ if (input.population === 0)
295
+ refusals.push({
296
+ code: 'hr.payroll.empty',
297
+ message: `${input.legalEntityName} employed nobody between ${input.periodStart} and ` +
298
+ `${input.periodEnd}. There is nothing to export.`,
299
+ personIds: [],
300
+ });
301
+ if (input.withoutEmployment.length) {
302
+ const one = input.withoutEmployment.length === 1;
303
+ refusals.push({
304
+ code: 'hr.payroll.no_employment',
305
+ message: (one
306
+ ? `${input.withoutEmployment[0]?.displayName} has no employment record covering their days `
307
+ : `${input.withoutEmployment.length} people have no employment record covering their days `) +
308
+ `in ${input.legalEntityName} over this period, so there is no basis to pay ` +
309
+ (one ? 'them on. ' : `them on: ${namesFor(input.withoutEmployment)}. `) +
310
+ 'Add an employment record, or move them to the entity that employs them.',
311
+ personIds: input.withoutEmployment.map((p) => p.personId),
312
+ });
313
+ }
314
+ return refusals;
315
+ }
316
+ /**
317
+ * People with an employee number first, in code-unit order; everybody else after them, by name.
318
+ *
319
+ * Deliberately **not** `localeCompare`: a frozen file format whose row order depends on the server's
320
+ * locale is one that produces two different files from one database, and a bureau diffing last
321
+ * month's against this month's would see every row move.
322
+ */
323
+ function byPayrollOrder(a, b) {
324
+ if ((a.employeeNo === null) !== (b.employeeNo === null))
325
+ return a.employeeNo === null ? 1 : -1;
326
+ if (a.employeeNo !== null && b.employeeNo !== null && a.employeeNo !== b.employeeNo)
327
+ return a.employeeNo < b.employeeNo ? -1 : 1;
328
+ if (a.displayName !== b.displayName)
329
+ return a.displayName < b.displayName ? -1 : 1;
330
+ return a.personId < b.personId ? -1 : a.personId > b.personId ? 1 : 0;
331
+ }
332
+ /** The manifest, without the files — shared by the export and its preview. */
333
+ export function exportManifest(input) {
334
+ const header = {
335
+ legalEntityId: input.entity.id,
336
+ legalEntityName: input.entity.name,
337
+ periodStart: input.period.startsOn,
338
+ periodEnd: input.period.endsOn,
339
+ };
340
+ const named = (file) => exportFilename({
341
+ entityName: input.entity.name,
342
+ from: header.periodStart,
343
+ to: header.periodEnd,
344
+ file,
345
+ draft: input.draft,
346
+ });
347
+ return {
348
+ contract: PAYROLL_EXPORT_CONTRACT,
349
+ generatedAt: input.generatedAt,
350
+ kernVersion: input.kernVersion,
351
+ finality: input.draft ? 'draft' : 'final',
352
+ draft: input.draft,
353
+ legalEntityId: input.entity.id,
354
+ legalEntityName: input.entity.name,
355
+ country: input.entity.country,
356
+ currency: input.entity.currency,
357
+ periodId: input.period.id,
358
+ periodStart: input.period.startsOn,
359
+ periodEnd: input.period.endsOn,
360
+ periodStatus: input.period.status,
361
+ population: input.population,
362
+ counted: input.counted,
363
+ scope: { permissions: input.permissions, askedAt: 'workspace' },
364
+ attendance: input.attendance,
365
+ dayLengthMinutes: input.dayLengthMinutes,
366
+ format: {
367
+ encoding: 'utf-8',
368
+ byteOrderMark: true,
369
+ delimiter: ',',
370
+ lineEnding: 'crlf',
371
+ quoting: 'rfc4180',
372
+ decimalSeparator: '.',
373
+ decimalPlaces: 2,
374
+ dateFormat: 'iso-8601',
375
+ },
376
+ files: [
377
+ { name: named('hours'), columns: [...PAYROLL_HOURS_COLUMNS], rows: input.hours.length },
378
+ { name: named('leave'), columns: [...PAYROLL_LEAVE_COLUMNS], rows: input.leave.length },
379
+ ],
380
+ };
381
+ }
382
+ /**
383
+ * The three files, from rows that have already been fetched.
384
+ *
385
+ * Pure, so the test pins the bytes without a database: the column order, the quoting, the empty
386
+ * `beyond_cap_minutes`, the filenames and the manifest beside them are all decided here.
387
+ */
388
+ export function assembleExport(input) {
389
+ const manifest = exportManifest(input);
390
+ const header = {
391
+ legalEntityId: input.entity.id,
392
+ legalEntityName: input.entity.name,
393
+ periodStart: input.period.startsOn,
394
+ periodEnd: input.period.endsOn,
395
+ };
396
+ const hoursFile = manifest.files[0];
397
+ const leaveFile = manifest.files[1];
398
+ return {
399
+ manifest,
400
+ files: [
401
+ {
402
+ name: hoursFile.name,
403
+ contentType: 'text/csv; charset=utf-8',
404
+ content: csvDocument(PAYROLL_HOURS_COLUMNS, input.hours.map((row) => hoursCsvRow(header, row))),
405
+ },
406
+ {
407
+ name: leaveFile.name,
408
+ contentType: 'text/csv; charset=utf-8',
409
+ content: csvDocument(PAYROLL_LEAVE_COLUMNS, input.leave.map((row) => leaveCsvRow(header, row))),
410
+ },
411
+ {
412
+ name: exportFilename({
413
+ entityName: input.entity.name,
414
+ from: input.period.startsOn,
415
+ to: input.period.endsOn,
416
+ file: 'manifest',
417
+ draft: input.draft,
418
+ }),
419
+ contentType: 'application/json; charset=utf-8',
420
+ content: `${JSON.stringify(manifest, null, 2)}\n`,
421
+ },
422
+ ],
423
+ };
424
+ }
425
+ export class PayrollExportService {
426
+ reports;
427
+ constructor(reports) {
428
+ this.reports = reports;
429
+ }
430
+ /**
431
+ * One entity, one period, every row — and every reason it should not be written.
432
+ *
433
+ * The refusals are *returned* rather than thrown, because `payroll.export.preview` has to show all
434
+ * of them at once on a screen. `payroll.export.v1` throws the first.
435
+ *
436
+ * The membership question is the whole design in one line: a row is keyed by (entity, period,
437
+ * person), and a person who transfers mid-period produces two rows, one in each entity's file,
438
+ * each carrying only that entity's days. `ReportsService.population` already answers it — the
439
+ * ladder resolves `employment.legalEntityId ?? office.legalEntityId` per **date**, which is what
440
+ * `setPeriodLock` does on the write side. Attributing the whole period to the entity somebody
441
+ * happened to end it in would report one employer's hours under the other's name and be wrong for
442
+ * both.
443
+ */
444
+ async collect(tx, input) {
445
+ const entity = await this.entity(tx, input.workspaceId, input.legalEntityId);
446
+ if (!entity)
447
+ throw KernError.notFound('Legal entity');
448
+ const period = await this.period(tx, input.workspaceId, input.periodId);
449
+ if (!period)
450
+ throw KernError.notFound('Period');
451
+ if (period.kind !== 'payroll')
452
+ throw KernError.badRequest('That period is an attendance period, not a payroll one. A payroll export takes a payroll period.');
453
+ // A period naming an entity covers only that entity; one naming none covers the workspace. The
454
+ // same rule `PolicyService.isLocked` applies, so a lock and its export cannot disagree.
455
+ if (period.legalEntityId && period.legalEntityId !== entity.id)
456
+ throw KernError.badRequest(`That period belongs to another legal entity. Lock and export ${entity.name}'s own period.`);
457
+ const from = period.startsOn;
458
+ const to = period.endsOn;
459
+ // A period is a month and this never fires in practice — but `periods.create` takes any two
460
+ // dates, and the refusal names both numbers rather than running for minutes.
461
+ const refusal = rangeRefusal({ from, to, perDay: true });
462
+ if (refusal)
463
+ throw KernError.badRequest(refusal);
464
+ const population = await this.reports.population(tx, input.workspaceId, { by: 'legal_entity', id: entity.id }, from, to);
465
+ // One pair of queries per set of people who belonged to the entity on the same days — which for a
466
+ // month nobody transferred through is one pair for the whole entity.
467
+ const groups = this.reports.groupsFor(population, from, to);
468
+ const dayRows = [];
469
+ const leaveRows = [];
470
+ for (const group of groups) {
471
+ dayRows.push(...(await this.reports.dayAggregate(tx, input.workspaceId, group, from, to)));
472
+ leaveRows.push(...(await this.leaveAggregate(tx, input.workspaceId, group, from, to)));
473
+ }
474
+ const facts = await this.peopleFacts(tx, input.workspaceId, population.personIds);
475
+ const employment = await this.employmentFacts(tx, input.workspaceId, entity.id, population.personIds, population.datesByPerson, from, to);
476
+ const dayByPerson = new Map(dayRows.map((r) => [r.personId, r]));
477
+ const leaveByPerson = new Map();
478
+ for (const row of leaveRows) {
479
+ const found = leaveByPerson.get(row.personId);
480
+ if (found)
481
+ found.push(row);
482
+ else
483
+ leaveByPerson.set(row.personId, [row]);
484
+ }
485
+ const withoutEmployment = [];
486
+ const hours = [];
487
+ for (const personId of population.personIds) {
488
+ const person = facts.get(personId);
489
+ const mine = employment.get(personId);
490
+ const displayName = person?.displayName ?? '';
491
+ if (!mine)
492
+ withoutEmployment.push({ personId, displayName });
493
+ const day = dayByPerson.get(personId);
494
+ const leave = leaveByPerson.get(personId) ?? [];
495
+ const paidLeaveDays = leave.filter((l) => l.paid).reduce((total, l) => total + l.days, 0);
496
+ const unpaidLeaveDays = leave.filter((l) => !l.paid).reduce((total, l) => total + l.days, 0);
497
+ hours.push({
498
+ personId,
499
+ employeeNo: person?.employeeNo ?? null,
500
+ displayName,
501
+ // Empty rather than invented where there is no employment row. The row is refused above; it
502
+ // is still built, because the preview has to show the reader which people the refusal is about.
503
+ employmentType: mine?.employmentType ?? '',
504
+ fte: mine?.fte ?? 0,
505
+ contractHoursWeek: mine?.contractHoursWeek ?? null,
506
+ costCenterCode: mine?.costCenterCode ?? null,
507
+ positionTitle: mine?.positionTitle ?? null,
508
+ hiredOn: person?.hiredOn ?? null,
509
+ terminatedOn: person?.terminatedOn ?? null,
510
+ employmentChangedInPeriod: mine?.changedInPeriod ?? false,
511
+ daySheets: day?.days ?? 0,
512
+ scheduledMinutes: day?.scheduledMinutes ?? 0,
513
+ workedMinutes: day?.workedMinutes ?? 0,
514
+ scheduledWorkedMinutes: day?.scheduledWorkedMinutes ?? 0,
515
+ breakMinutes: day?.breakMinutes ?? 0,
516
+ overtimeMinutes: day?.overtimeMinutes ?? 0,
517
+ lateMinutes: day?.lateMinutes ?? 0,
518
+ earlyLeaveMinutes: day?.earlyLeaveMinutes ?? 0,
519
+ // `?? null` and never `?? 0`: somebody with no day sheet at all had no ceiling asked of them.
520
+ beyondCapMinutes: day?.beyondCapMinutes ?? null,
521
+ cappedDays: day?.cappedDays ?? 0,
522
+ uncappedDays: day?.uncappedDays ?? 0,
523
+ lockedDays: day?.lockedDays ?? 0,
524
+ openDays: day?.openDays ?? 0,
525
+ paidLeaveDays: round2(paidLeaveDays),
526
+ unpaidLeaveDays: round2(unpaidLeaveDays),
527
+ });
528
+ }
529
+ hours.sort(byPayrollOrder);
530
+ const leave = leaveRows
531
+ .map((row) => ({
532
+ personId: row.personId,
533
+ employeeNo: facts.get(row.personId)?.employeeNo ?? null,
534
+ leaveTypeKey: row.leaveTypeKey,
535
+ leaveTypeName: row.leaveTypeName,
536
+ paid: row.paid,
537
+ unit: row.unit,
538
+ days: round2(row.days),
539
+ requests: row.requests,
540
+ }))
541
+ .sort((a, b) => {
542
+ const person = byPayrollOrder({
543
+ employeeNo: a.employeeNo,
544
+ displayName: facts.get(a.personId)?.displayName ?? '',
545
+ personId: a.personId,
546
+ }, {
547
+ employeeNo: b.employeeNo,
548
+ displayName: facts.get(b.personId)?.displayName ?? '',
549
+ personId: b.personId,
550
+ });
551
+ return person !== 0
552
+ ? person
553
+ : a.leaveTypeKey < b.leaveTypeKey
554
+ ? -1
555
+ : a.leaveTypeKey > b.leaveTypeKey
556
+ ? 1
557
+ : 0;
558
+ });
559
+ const counted = new Set([...dayByPerson.keys(), ...leaveByPerson.keys()]).size;
560
+ return {
561
+ entity,
562
+ period: { id: period.id, startsOn: from, endsOn: to, status: period.status },
563
+ population: population.personIds.length,
564
+ counted,
565
+ attendance: mergeFinality(dayRows),
566
+ hours,
567
+ leave,
568
+ totals: totalsOf(hours),
569
+ refusals: exportRefusals({
570
+ legalEntityName: entity.name,
571
+ periodStart: from,
572
+ periodEnd: to,
573
+ periodStatus: period.status,
574
+ draft: input.draft,
575
+ population: population.personIds.length,
576
+ withoutEmployment,
577
+ }),
578
+ };
579
+ }
580
+ /** The employer this file is addressed on behalf of: the fields a filing is made under. */
581
+ async entity(tx, workspaceId, legalEntityId) {
582
+ const [row] = await tx
583
+ .select({
584
+ id: legalEntities.id,
585
+ name: legalEntities.name,
586
+ country: legalEntities.country,
587
+ currency: legalEntities.currency,
588
+ })
589
+ .from(legalEntities)
590
+ .where(and(eq(legalEntities.workspaceId, workspaceId), eq(legalEntities.id, legalEntityId)))
591
+ .limit(1);
592
+ return row;
593
+ }
594
+ /** The period, which is the range. A caller does not get to draw their own boundary. */
595
+ async period(tx, workspaceId, periodId) {
596
+ const [row] = await tx
597
+ .select({
598
+ id: periods.id,
599
+ kind: periods.kind,
600
+ legalEntityId: periods.legalEntityId,
601
+ startsOn: periods.startsOn,
602
+ endsOn: periods.endsOn,
603
+ status: periods.status,
604
+ })
605
+ .from(periods)
606
+ .where(and(eq(periods.workspaceId, workspaceId), eq(periods.id, periodId)))
607
+ .limit(1);
608
+ return row;
609
+ }
610
+ /** Identity and the two dates a provider prorates a joiner or a leaver on. */
611
+ async peopleFacts(tx, workspaceId, personIds) {
612
+ const out = new Map();
613
+ if (!personIds.length)
614
+ return out;
615
+ const rows = await tx
616
+ .select({
617
+ id: people.id,
618
+ employeeNo: people.employeeNo,
619
+ displayName: people.displayName,
620
+ hiredOn: people.hiredOn,
621
+ terminatedOn: people.terminatedOn,
622
+ })
623
+ .from(people)
624
+ .where(and(eq(people.workspaceId, workspaceId), inArray(people.id, personIds)));
625
+ for (const row of rows)
626
+ out.set(row.id, {
627
+ employeeNo: row.employeeNo,
628
+ displayName: row.displayName,
629
+ hiredOn: row.hiredOn,
630
+ terminatedOn: row.terminatedOn,
631
+ });
632
+ return out;
633
+ }
634
+ /**
635
+ * The employment facts that let a provider pick a rate, per person — and whether they moved.
636
+ *
637
+ * Two different questions over one set of rows, and answering both from the same filter is a bug
638
+ * this had until it was run against a database:
639
+ *
640
+ * **Which facts to publish** is a question about *this entity's days*. A row qualifies when it
641
+ * overlaps the days the person belonged to this entity and it either names this entity or names
642
+ * none — the second half because the ladder falls back from the employment to the office, so
643
+ * somebody employed with a null entity is genuinely in this file through their desk. The **last**
644
+ * qualifying row wins, so a transferring person contributes their TR row to the TR file and their
645
+ * NL row to the NL file, and neither file carries the other's FTE.
646
+ *
647
+ * **Whether anything changed** is a question about *the period*, over every row, entity or not.
648
+ * Counting only this entity's rows reports `false` for the one case the column exists for: somebody
649
+ * who left the entity on the 15th looks, in this file, like a full-time employee of it for a period
650
+ * running to the 30th, and a monthly-salaried person is then paid a whole month here and a partial
651
+ * month next door. `day_sheets` hints at it and a flag states it. Publishing the end state silently
652
+ * is the failure; the flag is what makes it visible without this module inventing a weighted
653
+ * average nobody asked for.
654
+ */
655
+ async employmentFacts(tx, workspaceId, legalEntityId, personIds, datesByPerson, from, to) {
656
+ const out = new Map();
657
+ if (!personIds.length)
658
+ return out;
659
+ // Every row overlapping the period, whichever entity it names. The entity narrowing happens
660
+ // below, on the facts alone, because `changedInPeriod` has to see the rows this file excludes.
661
+ const rows = await tx
662
+ .select({
663
+ personId: employments.personId,
664
+ effectiveFrom: employments.effectiveFrom,
665
+ effectiveTo: employments.effectiveTo,
666
+ legalEntityId: employments.legalEntityId,
667
+ employmentType: employments.employmentType,
668
+ fte: employments.fte,
669
+ contractHoursWeek: employments.contractHoursWeek,
670
+ costCenterCode: costCenters.code,
671
+ positionTitle: positions.title,
672
+ })
673
+ .from(employments)
674
+ .leftJoin(costCenters, eq(costCenters.id, employments.costCenterId))
675
+ .leftJoin(positions, eq(positions.id, employments.positionId))
676
+ .where(and(eq(employments.workspaceId, workspaceId), inArray(employments.personId, personIds), lte(employments.effectiveFrom, to), or(isNull(employments.effectiveTo), gte(employments.effectiveTo, from))))
677
+ .orderBy(asc(employments.personId), asc(employments.effectiveFrom));
678
+ for (const personId of personIds) {
679
+ const mine = rows.filter((r) => r.personId === personId);
680
+ // The days this person was in this entity. `min`..`max` rather than the exact set: a wider
681
+ // window can only ever admit an extra employment row, and admitting one is the honest direction
682
+ // to be wrong in against silently publishing one of two FTEs.
683
+ const dates = datesByPerson?.get(personId);
684
+ const windowFrom = dates?.[0] ?? from;
685
+ const windowTo = dates?.[dates.length - 1] ?? to;
686
+ const here = mine.filter((r) => r.effectiveFrom <= windowTo &&
687
+ (r.effectiveTo === null || r.effectiveTo >= windowFrom) &&
688
+ (r.legalEntityId === null || r.legalEntityId === legalEntityId));
689
+ const last = here[here.length - 1];
690
+ if (!last)
691
+ continue;
692
+ out.set(personId, {
693
+ employmentType: last.employmentType,
694
+ fte: num(last.fte, 0),
695
+ contractHoursWeek: last.contractHoursWeek === null ? null : num(last.contractHoursWeek),
696
+ costCenterCode: last.costCenterCode,
697
+ positionTitle: last.positionTitle,
698
+ // `mine`, not `here`: a transfer out of this entity is a change this file has to declare,
699
+ // and it is invisible in `here` by construction.
700
+ changedInPeriod: mine.length > 1,
701
+ });
702
+ }
703
+ return out;
704
+ }
705
+ /**
706
+ * Approved leave, per person per type, aggregated in the database.
707
+ *
708
+ * `leave_request_days` filtered `counted and status = 'approved'`, joined out to `leave_requests` →
709
+ * `leave_types`. Three things this is deliberately not:
710
+ *
711
+ * - **not `leave_ledger`**, which is the balance and carries grants, accruals, carry-in and expiry
712
+ * — movements that are not leave anybody took in this period;
713
+ * - **not `leave_requests.minutes` summed per day**, which counts a five-day request five times;
714
+ * - **not converted to minutes**, because `fraction` is exact and `MINUTES_PER_DAY` is a hardcoded
715
+ * eight hours. The manifest publishes the day length for anyone who wants to convert.
716
+ *
717
+ * `sum(fraction)` cannot double count: `hr_leave_days_no_double_booking` refuses a second live row
718
+ * on one person-day, so the join fans out to exactly one row per date.
719
+ */
720
+ async leaveAggregate(tx, workspaceId, group, from, to) {
721
+ const where = [
722
+ eq(leaveRequestDays.workspaceId, workspaceId),
723
+ eq(leaveRequestDays.counted, true),
724
+ eq(leaveRequestDays.status, 'approved'),
725
+ ];
726
+ if (group.dates === null) {
727
+ where.push(gte(leaveRequestDays.date, from), lte(leaveRequestDays.date, to));
728
+ }
729
+ else {
730
+ if (!group.dates.length)
731
+ return [];
732
+ where.push(inArray(leaveRequestDays.date, group.dates));
733
+ }
734
+ if (group.personIds !== null) {
735
+ if (!group.personIds.length)
736
+ return [];
737
+ where.push(inArray(leaveRequestDays.personId, group.personIds));
738
+ }
739
+ const rows = await tx
740
+ .select({
741
+ personId: leaveRequestDays.personId,
742
+ leaveTypeKey: leaveTypes.key,
743
+ leaveTypeName: leaveTypes.name,
744
+ paid: leaveTypes.paid,
745
+ unit: leaveTypes.unit,
746
+ days: sql `coalesce(sum(${leaveRequestDays.fraction}), 0)`,
747
+ requests: sql `count(distinct ${leaveRequestDays.requestId})`,
748
+ })
749
+ .from(leaveRequestDays)
750
+ .innerJoin(leaveRequests, eq(leaveRequests.id, leaveRequestDays.requestId))
751
+ .innerJoin(leaveTypes, eq(leaveTypes.id, leaveRequests.leaveTypeId))
752
+ .where(and(...where))
753
+ .groupBy(leaveRequestDays.personId, leaveTypes.id, leaveTypes.key, leaveTypes.name, leaveTypes.paid, leaveTypes.unit);
754
+ return rows.map((r) => ({
755
+ personId: r.personId,
756
+ leaveTypeKey: r.leaveTypeKey,
757
+ leaveTypeName: r.leaveTypeName,
758
+ paid: r.paid,
759
+ unit: r.unit,
760
+ days: num(r.days),
761
+ requests: Number(r.requests ?? 0),
762
+ }));
763
+ }
764
+ }
765
+ //# sourceMappingURL=exports.js.map