@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.
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +26 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/exports.d.ts +459 -0
- package/dist/contract/exports.d.ts.map +1 -0
- package/dist/contract/exports.js +331 -0
- package/dist/contract/exports.js.map +1 -0
- 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 +32 -3
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/reports.d.ts +4 -0
- package/dist/contract/reports.d.ts.map +1 -1
- package/dist/contract/reports.js +11 -1
- package/dist/contract/reports.js.map +1 -1
- package/dist/contract/router.d.ts +274 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +67 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +285 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +120 -2
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/exports.d.ts +321 -0
- package/dist/server/services/exports.d.ts.map +1 -0
- package/dist/server/services/exports.js +765 -0
- package/dist/server/services/exports.js.map +1 -0
- 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 +2 -0
- package/dist/server/services/reports.d.ts.map +1 -1
- package/dist/server/services/reports.js +14 -7
- package/dist/server/services/reports.js.map +1 -1
- package/package.json +1 -1
- package/src/client/messages.ts +13 -0
- package/src/contract/capabilities.ts +26 -0
- package/src/contract/exports.ts +354 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/permissions.ts +34 -3
- package/src/contract/reports.ts +11 -1
- 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
|