@kernhq/module-hr 0.15.0 → 0.16.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/server/index.d.ts.map +1 -1
- package/dist/server/index.js +51 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +44 -7
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +118 -1
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +447 -273
- package/dist/server/router.js.map +1 -1
- package/package.json +1 -1
- package/src/client/messages.ts +17 -0
- package/src/client/widgets/ApprovalsWidget.svelte +119 -8
package/dist/server/router.js
CHANGED
|
@@ -84,6 +84,332 @@ function paginate(rows, limit, cursorOf) {
|
|
|
84
84
|
const [key, id] = cursorOf(items[items.length - 1]);
|
|
85
85
|
return { items, nextCursor: encodeCursor(key, id) };
|
|
86
86
|
}
|
|
87
|
+
async function loadCalendar(tx, workspaceId, calendarId) {
|
|
88
|
+
const [row] = await tx
|
|
89
|
+
.select()
|
|
90
|
+
.from(calendars)
|
|
91
|
+
.where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
|
|
92
|
+
.limit(1);
|
|
93
|
+
if (!row)
|
|
94
|
+
throw KernError.notFound('Calendar');
|
|
95
|
+
return row;
|
|
96
|
+
}
|
|
97
|
+
/** The chain nearest-first: this calendar, then whatever it extends. */
|
|
98
|
+
async function calendarChain(tx, workspaceId, calendarId) {
|
|
99
|
+
const chain = [];
|
|
100
|
+
let cursor = calendarId;
|
|
101
|
+
for (let depth = 0; depth < 4 && cursor; depth++) {
|
|
102
|
+
const row = await loadCalendar(tx, workspaceId, cursor);
|
|
103
|
+
chain.push(row);
|
|
104
|
+
cursor = row.extendsId;
|
|
105
|
+
}
|
|
106
|
+
return chain;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The composed calendar over a range: this calendar's days over the ones it extends.
|
|
110
|
+
*
|
|
111
|
+
* Nearest wins per date and kind, and a day that shadows one from a calendar further down is
|
|
112
|
+
* marked `overrides` so the editor can show what it is replacing — which is what makes "we work
|
|
113
|
+
* through this national holiday" legible rather than looking like a missing holiday.
|
|
114
|
+
*
|
|
115
|
+
* At module scope, because it never needed the router's closure — only a `tx`. That is what lets
|
|
116
|
+
* `hrSubjects` below reach it, so the leave calculation a deadline runs reads the same calendar as
|
|
117
|
+
* the one a person runs.
|
|
118
|
+
*/
|
|
119
|
+
async function composedDays(tx, workspaceId, calendarId, from, to) {
|
|
120
|
+
const chain = await calendarChain(tx, workspaceId, calendarId);
|
|
121
|
+
const rows = await tx
|
|
122
|
+
.select()
|
|
123
|
+
.from(calendarDays)
|
|
124
|
+
.where(and(eq(calendarDays.workspaceId, workspaceId), inArray(calendarDays.calendarId, chain.map((c) => c.id)), gte(calendarDays.date, from), lte(calendarDays.date, to)));
|
|
125
|
+
const nameById = new Map(chain.map((c) => [c.id, c.name]));
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
const datesFromNearest = new Set();
|
|
128
|
+
for (const cal of chain) {
|
|
129
|
+
for (const row of rows.filter((r) => r.calendarId === cal.id)) {
|
|
130
|
+
const key = `${row.date}:${row.kind}`;
|
|
131
|
+
if (seen.has(key))
|
|
132
|
+
continue;
|
|
133
|
+
const overrides = cal.id !== calendarId ? false : datesFromNearest.has(row.date);
|
|
134
|
+
seen.set(key, toResolvedDay(row, cal.id, nameById.get(cal.id) ?? '', overrides));
|
|
135
|
+
if (cal.id === calendarId)
|
|
136
|
+
datesFromNearest.add(row.date);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Second pass: a nearest-calendar day covering a date the base also has *is* an override, and
|
|
140
|
+
// the first pass cannot know that until the base has been read.
|
|
141
|
+
const baseDates = new Set(rows.filter((r) => r.calendarId !== calendarId).map((r) => r.date));
|
|
142
|
+
return [...seen.values()]
|
|
143
|
+
.map((d) => ({ ...d, overrides: d.fromCalendarId === calendarId && baseDates.has(d.date) }))
|
|
144
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* What a *decided* request does to its subject, and the calculation both halves of that stand on.
|
|
148
|
+
*
|
|
149
|
+
* These lived inside `implement_`'s closure, and that is the whole reason `ApprovalService` had
|
|
150
|
+
* appliers only when a person was on the other end of the call: a job cannot reach into a router's
|
|
151
|
+
* closure, so `sweepTimeouts` would advance an intermediate step and then refuse the step that
|
|
152
|
+
* *completes* a request — logging that it had reminded instead. A deadline an administrator set,
|
|
153
|
+
* believed, and told their staff about did nothing on the one step that mattered.
|
|
154
|
+
*
|
|
155
|
+
* So it is a factory both callers can reach: `implement_` below, and `hrJobs` in `jobs.ts`. It is a
|
|
156
|
+
* factory rather than a class because the closure is the point — `applyApproval` needs `simulate`,
|
|
157
|
+
* `simulate` needs the ledger and the composed calendar, and threading those through method
|
|
158
|
+
* arguments would buy nothing.
|
|
159
|
+
*
|
|
160
|
+
* The alternative was a second `simulate` in the job, and it is worth naming why not: two copies of
|
|
161
|
+
* a leave calculation drift, both of them type-check while they drift, and the first sign of it is
|
|
162
|
+
* an employee whose balance disagrees with the days they were granted.
|
|
163
|
+
*
|
|
164
|
+
* Services are passed in rather than constructed here so a caller keeps one instance of each — two
|
|
165
|
+
* `AttendanceService`s is not a bug today and is one cache away from being one.
|
|
166
|
+
*/
|
|
167
|
+
export function hrSubjects(deps) {
|
|
168
|
+
const { resolve, ledger, attendance } = deps;
|
|
169
|
+
async function loadRequest(tx, workspaceId, requestId) {
|
|
170
|
+
const [row] = await tx
|
|
171
|
+
.select()
|
|
172
|
+
.from(leaveRequests)
|
|
173
|
+
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
|
|
174
|
+
.limit(1);
|
|
175
|
+
if (!row)
|
|
176
|
+
throw KernError.notFound('Leave request');
|
|
177
|
+
return row;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* What a request would cost, and every reason it would be refused.
|
|
181
|
+
*
|
|
182
|
+
* Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
|
|
183
|
+
* submission is a preview that eventually lies. The blockers are returned rather than thrown here
|
|
184
|
+
* so the screen can show all of them at once instead of one per round trip.
|
|
185
|
+
*/
|
|
186
|
+
async function simulate(tx, workspaceId, personId, input) {
|
|
187
|
+
const blockers = [];
|
|
188
|
+
if (input.endsOn < input.startsOn)
|
|
189
|
+
blockers.push({ code: 'range', message: 'The end date is before the start date.' });
|
|
190
|
+
const [type] = await tx
|
|
191
|
+
.select()
|
|
192
|
+
.from(leaveTypes)
|
|
193
|
+
.where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
|
|
194
|
+
.limit(1);
|
|
195
|
+
if (!type)
|
|
196
|
+
throw KernError.notFound('Leave type');
|
|
197
|
+
if (type.archivedAt)
|
|
198
|
+
blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
|
|
199
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
|
|
200
|
+
const calendarDaysInRange = resolution.calendarId
|
|
201
|
+
? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
|
|
202
|
+
: [];
|
|
203
|
+
const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
|
|
204
|
+
? calendarDaysInRange.map((d) => ({
|
|
205
|
+
date: d.date,
|
|
206
|
+
name: d.name,
|
|
207
|
+
workingFraction: d.workingFraction,
|
|
208
|
+
}))
|
|
209
|
+
: []);
|
|
210
|
+
// Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
|
|
211
|
+
// holiday still costs nothing rather than costing half of nothing.
|
|
212
|
+
const days = results.map((r) => {
|
|
213
|
+
let fraction = r.fraction;
|
|
214
|
+
if (r.date === input.startsOn && input.startPart === 'afternoon')
|
|
215
|
+
fraction = Math.min(fraction, 0.5);
|
|
216
|
+
if (r.date === input.endsOn && input.endPart === 'morning')
|
|
217
|
+
fraction = Math.min(fraction, 0.5);
|
|
218
|
+
return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
|
|
219
|
+
});
|
|
220
|
+
const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
|
|
221
|
+
const minutes = type.unit === 'hour' && input.hours
|
|
222
|
+
? Math.round(input.hours * 60)
|
|
223
|
+
: Math.round(workingDaysTotal * MINUTES_PER_DAY);
|
|
224
|
+
if (minutes <= 0)
|
|
225
|
+
blockers.push({
|
|
226
|
+
code: 'empty',
|
|
227
|
+
message: 'That range contains no working days.',
|
|
228
|
+
});
|
|
229
|
+
const year = yearOf(input.startsOn);
|
|
230
|
+
const balances = await ledger.balances(tx, workspaceId, personId, year);
|
|
231
|
+
const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
|
|
232
|
+
const before = balance?.availableMinutes ?? 0;
|
|
233
|
+
const after = before - minutes;
|
|
234
|
+
if (after < 0 && !type.allowNegative)
|
|
235
|
+
blockers.push({
|
|
236
|
+
code: 'insufficient',
|
|
237
|
+
message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
|
|
238
|
+
});
|
|
239
|
+
if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
|
|
240
|
+
blockers.push({
|
|
241
|
+
code: 'below_floor',
|
|
242
|
+
message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
|
|
243
|
+
});
|
|
244
|
+
// Overlap is refused by a unique index as well; checking here turns a constraint violation into
|
|
245
|
+
// a sentence naming the dates.
|
|
246
|
+
const counted = days.filter((d) => d.counted).map((d) => d.date);
|
|
247
|
+
if (counted.length) {
|
|
248
|
+
const clash = await tx
|
|
249
|
+
.select({ date: leaveRequestDays.date })
|
|
250
|
+
.from(leaveRequestDays)
|
|
251
|
+
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
|
|
252
|
+
.limit(3);
|
|
253
|
+
if (clash.length)
|
|
254
|
+
blockers.push({
|
|
255
|
+
code: 'overlap',
|
|
256
|
+
message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
|
|
260
|
+
blockers.push({
|
|
261
|
+
code: 'document_required',
|
|
262
|
+
message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
workingDays: workingDaysTotal,
|
|
266
|
+
minutes,
|
|
267
|
+
days,
|
|
268
|
+
balanceBeforeMinutes: before,
|
|
269
|
+
balanceAfterMinutes: after,
|
|
270
|
+
blockers,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Turn an approved request into a ledger consumption.
|
|
275
|
+
*
|
|
276
|
+
* The working days are **recomputed here** rather than trusted from submission time: a holiday
|
|
277
|
+
* can be added to the calendar between asking and approving, and the number that costs somebody
|
|
278
|
+
* balance should be the one that was true when it was granted.
|
|
279
|
+
*/
|
|
280
|
+
async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
|
|
281
|
+
const request = await loadRequest(tx, workspaceId, leaveRequestId);
|
|
282
|
+
if (request.status === 'approved')
|
|
283
|
+
return;
|
|
284
|
+
const sim = await simulate(tx, workspaceId, request.personId, {
|
|
285
|
+
leaveTypeId: request.leaveTypeId,
|
|
286
|
+
startsOn: request.startsOn,
|
|
287
|
+
endsOn: request.endsOn,
|
|
288
|
+
startPart: request.startPart,
|
|
289
|
+
endPart: request.endPart,
|
|
290
|
+
hours: request.hours === null ? null : Number.parseFloat(request.hours),
|
|
291
|
+
});
|
|
292
|
+
await ledger.append(tx, workspaceId, {
|
|
293
|
+
personId: request.personId,
|
|
294
|
+
leaveTypeId: request.leaveTypeId,
|
|
295
|
+
kind: 'consumption',
|
|
296
|
+
amountMinutes: -sim.minutes,
|
|
297
|
+
effectiveOn: request.startsOn,
|
|
298
|
+
periodYear: yearOf(request.startsOn),
|
|
299
|
+
requestId: request.id,
|
|
300
|
+
reason: null,
|
|
301
|
+
createdBy: actorId,
|
|
302
|
+
});
|
|
303
|
+
await tx
|
|
304
|
+
.update(leaveRequestDays)
|
|
305
|
+
.set({ status: 'approved' })
|
|
306
|
+
.where(eq(leaveRequestDays.requestId, request.id));
|
|
307
|
+
await tx
|
|
308
|
+
.update(leaveRequests)
|
|
309
|
+
.set({
|
|
310
|
+
status: 'approved',
|
|
311
|
+
minutes: sim.minutes,
|
|
312
|
+
workingDays: String(sim.workingDays),
|
|
313
|
+
decidedAt: new Date(),
|
|
314
|
+
updatedAt: new Date(),
|
|
315
|
+
})
|
|
316
|
+
.where(eq(leaveRequests.id, request.id));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Everything a punch needs about a person: their zone, and the schedule that shapes their day.
|
|
320
|
+
*
|
|
321
|
+
* The zone comes from the resolution ladder — their primary office unless they have an override —
|
|
322
|
+
* so a punch made on a business trip still counts towards the month they are employed in.
|
|
323
|
+
*
|
|
324
|
+
* Everything here is resolved **as of today**, which is what a punch is about. It is therefore
|
|
325
|
+
* not the place to answer a question about a past date: this used to hand out today's legal
|
|
326
|
+
* entity as well, and three callers applied it to business dates months back — so a person who
|
|
327
|
+
* transferred entity had a filed month recomputed against the one they are in now. `recomputeDay`
|
|
328
|
+
* asks that question of the day it is rebuilding.
|
|
329
|
+
*/
|
|
330
|
+
async function personContext(tx, workspaceId, personId) {
|
|
331
|
+
const today = todayIso();
|
|
332
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
|
|
333
|
+
const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
|
|
334
|
+
return { timezone: resolution.timezone, schedule, resolution };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Apply an approved correction: write the proposed punches, void what they replace, rebuild.
|
|
338
|
+
*
|
|
339
|
+
* Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
|
|
340
|
+
* so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
|
|
341
|
+
* regularization exists rather than an update statement.
|
|
342
|
+
*/
|
|
343
|
+
async function applyRegularization(tx, workspaceId, regularizationId) {
|
|
344
|
+
const [row] = await tx
|
|
345
|
+
.select()
|
|
346
|
+
.from(regularizations)
|
|
347
|
+
.where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
|
|
348
|
+
.limit(1);
|
|
349
|
+
if (!row || row.status === 'approved')
|
|
350
|
+
return;
|
|
351
|
+
if (row.punchId)
|
|
352
|
+
await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
|
|
353
|
+
const { timezone, schedule } = await personContext(tx, workspaceId, row.personId);
|
|
354
|
+
for (const proposal of row.proposed)
|
|
355
|
+
await tx.insert(punches).values({
|
|
356
|
+
id: uuidv7(),
|
|
357
|
+
workspaceId,
|
|
358
|
+
personId: row.personId,
|
|
359
|
+
direction: proposal.direction,
|
|
360
|
+
at: new Date(proposal.at),
|
|
361
|
+
businessDate: row.businessDate,
|
|
362
|
+
timezone,
|
|
363
|
+
method: 'manual',
|
|
364
|
+
trust: 'trusted',
|
|
365
|
+
note: `Regularization ${row.id}`,
|
|
366
|
+
});
|
|
367
|
+
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
368
|
+
await tx
|
|
369
|
+
.update(regularizations)
|
|
370
|
+
.set({ status: 'approved', appliedAt: new Date() })
|
|
371
|
+
.where(eq(regularizations.id, row.id));
|
|
372
|
+
}
|
|
373
|
+
/** A rejected request costs no balance and writes no punches; it just stops being live. */
|
|
374
|
+
async function applyLeaveDecision(tx, workspaceId, leaveRequestId, status, actorId) {
|
|
375
|
+
if (status === 'approved')
|
|
376
|
+
return applyApproval(tx, workspaceId, leaveRequestId, actorId);
|
|
377
|
+
await tx
|
|
378
|
+
.update(leaveRequests)
|
|
379
|
+
.set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
|
|
380
|
+
.where(eq(leaveRequests.id, leaveRequestId));
|
|
381
|
+
}
|
|
382
|
+
/** The same, for a correction. */
|
|
383
|
+
async function applyRegularizationDecision(tx, workspaceId, regularizationId, status) {
|
|
384
|
+
if (status === 'approved')
|
|
385
|
+
return applyRegularization(tx, workspaceId, regularizationId);
|
|
386
|
+
await tx
|
|
387
|
+
.update(regularizations)
|
|
388
|
+
.set({ status: 'rejected' })
|
|
389
|
+
.where(eq(regularizations.id, regularizationId));
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
loadRequest,
|
|
393
|
+
simulate,
|
|
394
|
+
applyApproval,
|
|
395
|
+
applyRegularization,
|
|
396
|
+
personContext,
|
|
397
|
+
/**
|
|
398
|
+
* The same two functions in the shape `ApprovalService` calls them in, keyed by `subjectType` —
|
|
399
|
+
* the only thing the engine knows about a subject.
|
|
400
|
+
*
|
|
401
|
+
* Parameterised by the actor because that is the one thing the two callers genuinely disagree
|
|
402
|
+
* about: a person approving leave is written onto the ledger entry as `created_by`, and a
|
|
403
|
+
* deadline running out is written as nobody. Passing the approver's id for a timeout would put
|
|
404
|
+
* a name against a decision that person did not make, which is exactly what
|
|
405
|
+
* `TIMEOUT_APPROVER_ID` exists to avoid one table over.
|
|
406
|
+
*/
|
|
407
|
+
appliersFor: (actorId) => ({
|
|
408
|
+
leave: (tx, workspaceId, request, status) => applyLeaveDecision(tx, workspaceId, request.subjectId, status, actorId),
|
|
409
|
+
regularization: (tx, workspaceId, request, status) => applyRegularizationDecision(tx, workspaceId, request.subjectId, status),
|
|
410
|
+
}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
87
413
|
/**
|
|
88
414
|
* The router.
|
|
89
415
|
*
|
|
@@ -103,9 +429,16 @@ export function implement_(kernel) {
|
|
|
103
429
|
const svc = new PeopleService(kernel);
|
|
104
430
|
const access = new HrAccessService(kernel);
|
|
105
431
|
const ledger = new LedgerService();
|
|
106
|
-
const approvals = new ApprovalService(kernel);
|
|
107
432
|
const policySvc = new PolicyService(resolve);
|
|
108
433
|
const attendance = new AttendanceService(resolve, policySvc);
|
|
434
|
+
const subjects = hrSubjects({ resolve, ledger, attendance });
|
|
435
|
+
const { applyApproval, applyRegularization, loadRequest, personContext, simulate } = subjects;
|
|
436
|
+
/**
|
|
437
|
+
* The engine gets the appliers here as well as in `jobs.ts`, so the two constructions read the
|
|
438
|
+
* same and stay the same. A sweep started from a request is still nobody's decision, which is why
|
|
439
|
+
* this one is built for no actor — the per-request actor arrives at `decide` below.
|
|
440
|
+
*/
|
|
441
|
+
const approvals = new ApprovalService(kernel, subjects.appliersFor(null));
|
|
109
442
|
const db = kernel.database;
|
|
110
443
|
const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
|
|
111
444
|
const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
|
|
@@ -2036,10 +2369,17 @@ export function implement_(kernel) {
|
|
|
2036
2369
|
// Same reason as leave: the approvers are told after this commits, never inside it.
|
|
2037
2370
|
return {
|
|
2038
2371
|
row: fresh,
|
|
2039
|
-
approval: {
|
|
2372
|
+
approval: {
|
|
2373
|
+
requestId: raised.request.id,
|
|
2374
|
+
approverIds: raised.firstStepApprovers,
|
|
2375
|
+
userIds: await accountsOf(tx, input.workspaceId, raised.firstStepApprovers),
|
|
2376
|
+
summary: raised.request.summary,
|
|
2377
|
+
summaryParams: raised.request.summaryParams,
|
|
2378
|
+
actorId: raised.request.requestedBy,
|
|
2379
|
+
},
|
|
2040
2380
|
};
|
|
2041
2381
|
});
|
|
2042
|
-
if (filed.approval.approverIds.length)
|
|
2382
|
+
if (filed.approval.approverIds.length) {
|
|
2043
2383
|
await kernel.emit(hrEvents.approvalRequested, {
|
|
2044
2384
|
requestId: filed.approval.requestId,
|
|
2045
2385
|
workspaceId: input.workspaceId,
|
|
@@ -2047,6 +2387,16 @@ export function implement_(kernel) {
|
|
|
2047
2387
|
subjectId: filed.row.id,
|
|
2048
2388
|
approverIds: filed.approval.approverIds,
|
|
2049
2389
|
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
2390
|
+
await notifyApprovers({
|
|
2391
|
+
workspaceId: input.workspaceId,
|
|
2392
|
+
requestId: filed.approval.requestId,
|
|
2393
|
+
subjectType: 'regularization',
|
|
2394
|
+
summary: filed.approval.summary,
|
|
2395
|
+
summaryParams: filed.approval.summaryParams,
|
|
2396
|
+
userIds: filed.approval.userIds,
|
|
2397
|
+
actorId: filed.approval.actorId,
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2050
2400
|
await changed(input.workspaceId, 'regularization', filed.row.id, 'created');
|
|
2051
2401
|
return toRegularization(filed.row);
|
|
2052
2402
|
}),
|
|
@@ -2357,7 +2707,17 @@ export function implement_(kernel) {
|
|
|
2357
2707
|
request: fresh,
|
|
2358
2708
|
personId,
|
|
2359
2709
|
replay: false,
|
|
2360
|
-
approval: {
|
|
2710
|
+
approval: {
|
|
2711
|
+
requestId: raised.request.id,
|
|
2712
|
+
approverIds: raised.firstStepApprovers,
|
|
2713
|
+
// Resolved here because it needs `tx`, delivered outside because a notification
|
|
2714
|
+
// cannot be rolled back. Person ids are HR's identity and accounts are core's, so
|
|
2715
|
+
// the translation happens once, on the way out.
|
|
2716
|
+
userIds: await accountsOf(tx, input.workspaceId, raised.firstStepApprovers),
|
|
2717
|
+
summary: raised.request.summary,
|
|
2718
|
+
summaryParams: raised.request.summaryParams,
|
|
2719
|
+
actorId: raised.request.requestedBy,
|
|
2720
|
+
},
|
|
2361
2721
|
};
|
|
2362
2722
|
});
|
|
2363
2723
|
const result = await filing.catch(async (err) => {
|
|
@@ -2389,7 +2749,7 @@ export function implement_(kernel) {
|
|
|
2389
2749
|
// the people the *first* step is on. Nothing for a chain that resolved to nobody — that
|
|
2390
2750
|
// was approved on the way in and is not waiting on anyone. The ids are person ids, the
|
|
2391
2751
|
// same identity the rest of `hr.*` carries.
|
|
2392
|
-
if (result.approval.approverIds.length)
|
|
2752
|
+
if (result.approval.approverIds.length) {
|
|
2393
2753
|
await kernel.emit(hrEvents.approvalRequested, {
|
|
2394
2754
|
requestId: result.approval.requestId,
|
|
2395
2755
|
workspaceId: input.workspaceId,
|
|
@@ -2397,6 +2757,18 @@ export function implement_(kernel) {
|
|
|
2397
2757
|
subjectId: result.request.id,
|
|
2398
2758
|
approverIds: result.approval.approverIds,
|
|
2399
2759
|
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
2760
|
+
// And then the approvers themselves. The event is for other modules; this is for the
|
|
2761
|
+
// people whose signature the request is now waiting on.
|
|
2762
|
+
await notifyApprovers({
|
|
2763
|
+
workspaceId: input.workspaceId,
|
|
2764
|
+
requestId: result.approval.requestId,
|
|
2765
|
+
subjectType: 'leave',
|
|
2766
|
+
summary: result.approval.summary,
|
|
2767
|
+
summaryParams: result.approval.summaryParams,
|
|
2768
|
+
userIds: result.approval.userIds,
|
|
2769
|
+
actorId: result.approval.actorId,
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2400
2772
|
await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
|
|
2401
2773
|
return toLeaveRequest(result.request);
|
|
2402
2774
|
}),
|
|
@@ -2547,24 +2919,15 @@ export function implement_(kernel) {
|
|
|
2547
2919
|
// The approval engine knows nothing about leave. Applying the decision to the subject is
|
|
2548
2920
|
// the caller's job, which is what keeps the engine reusable for regularization and
|
|
2549
2921
|
// overtime later.
|
|
2922
|
+
//
|
|
2923
|
+
// Through the same appliers the timeout sweep is given, rather than a branch of its own:
|
|
2924
|
+
// this used to be a `switch` on `subjectType` here and nothing at all in the job, which is
|
|
2925
|
+
// how a deadline could approve a request and leave its leave unbooked. One table of
|
|
2926
|
+
// subject types, and adding overtime means adding a line to it and nothing here.
|
|
2550
2927
|
const request = result.request;
|
|
2551
|
-
if (
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
else if (result.status === 'rejected')
|
|
2555
|
-
await tx
|
|
2556
|
-
.update(leaveRequests)
|
|
2557
|
-
.set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
|
|
2558
|
-
.where(eq(leaveRequests.id, request.subjectId));
|
|
2559
|
-
}
|
|
2560
|
-
else if (request.subjectType === 'regularization') {
|
|
2561
|
-
if (result.status === 'approved')
|
|
2562
|
-
await applyRegularization(tx, input.workspaceId, request.subjectId);
|
|
2563
|
-
else if (result.status === 'rejected')
|
|
2564
|
-
await tx
|
|
2565
|
-
.update(regularizations)
|
|
2566
|
-
.set({ status: 'rejected' })
|
|
2567
|
-
.where(eq(regularizations.id, request.subjectId));
|
|
2928
|
+
if (result.status !== 'pending') {
|
|
2929
|
+
const apply = subjects.appliersFor(context.principal.userId ?? null)[request.subjectType];
|
|
2930
|
+
await apply?.(tx, input.workspaceId, request, result.status);
|
|
2568
2931
|
}
|
|
2569
2932
|
const [fresh] = await tx
|
|
2570
2933
|
.select()
|
|
@@ -2819,16 +3182,6 @@ export function implement_(kernel) {
|
|
|
2819
3182
|
throw KernError.notFound('Office');
|
|
2820
3183
|
return row;
|
|
2821
3184
|
}
|
|
2822
|
-
async function loadCalendar(tx, workspaceId, calendarId) {
|
|
2823
|
-
const [row] = await tx
|
|
2824
|
-
.select()
|
|
2825
|
-
.from(calendars)
|
|
2826
|
-
.where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
|
|
2827
|
-
.limit(1);
|
|
2828
|
-
if (!row)
|
|
2829
|
-
throw KernError.notFound('Calendar');
|
|
2830
|
-
return row;
|
|
2831
|
-
}
|
|
2832
3185
|
/** The workspace's calendar for a country pack, created on first use so offices can share one. */
|
|
2833
3186
|
async function packCalendar(tx, workspaceId, country) {
|
|
2834
3187
|
const [existing] = await tx
|
|
@@ -2892,51 +3245,6 @@ export function implement_(kernel) {
|
|
|
2892
3245
|
}
|
|
2893
3246
|
throw KernError.badRequest('Calendars may only be built on three levels.');
|
|
2894
3247
|
}
|
|
2895
|
-
/** The chain nearest-first: this calendar, then whatever it extends. */
|
|
2896
|
-
async function calendarChain(tx, workspaceId, calendarId) {
|
|
2897
|
-
const chain = [];
|
|
2898
|
-
let cursor = calendarId;
|
|
2899
|
-
for (let depth = 0; depth < 4 && cursor; depth++) {
|
|
2900
|
-
const row = await loadCalendar(tx, workspaceId, cursor);
|
|
2901
|
-
chain.push(row);
|
|
2902
|
-
cursor = row.extendsId;
|
|
2903
|
-
}
|
|
2904
|
-
return chain;
|
|
2905
|
-
}
|
|
2906
|
-
/**
|
|
2907
|
-
* The composed calendar over a range: this calendar's days over the ones it extends.
|
|
2908
|
-
*
|
|
2909
|
-
* Nearest wins per date and kind, and a day that shadows one from a calendar further down is
|
|
2910
|
-
* marked `overrides` so the editor can show what it is replacing — which is what makes "we work
|
|
2911
|
-
* through this national holiday" legible rather than looking like a missing holiday.
|
|
2912
|
-
*/
|
|
2913
|
-
async function composedDays(tx, workspaceId, calendarId, from, to) {
|
|
2914
|
-
const chain = await calendarChain(tx, workspaceId, calendarId);
|
|
2915
|
-
const rows = await tx
|
|
2916
|
-
.select()
|
|
2917
|
-
.from(calendarDays)
|
|
2918
|
-
.where(and(eq(calendarDays.workspaceId, workspaceId), inArray(calendarDays.calendarId, chain.map((c) => c.id)), gte(calendarDays.date, from), lte(calendarDays.date, to)));
|
|
2919
|
-
const nameById = new Map(chain.map((c) => [c.id, c.name]));
|
|
2920
|
-
const seen = new Map();
|
|
2921
|
-
const datesFromNearest = new Set();
|
|
2922
|
-
for (const cal of chain) {
|
|
2923
|
-
for (const row of rows.filter((r) => r.calendarId === cal.id)) {
|
|
2924
|
-
const key = `${row.date}:${row.kind}`;
|
|
2925
|
-
if (seen.has(key))
|
|
2926
|
-
continue;
|
|
2927
|
-
const overrides = cal.id !== calendarId ? false : datesFromNearest.has(row.date);
|
|
2928
|
-
seen.set(key, toResolvedDay(row, cal.id, nameById.get(cal.id) ?? '', overrides));
|
|
2929
|
-
if (cal.id === calendarId)
|
|
2930
|
-
datesFromNearest.add(row.date);
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
// Second pass: a nearest-calendar day covering a date the base also has *is* an override, and
|
|
2934
|
-
// the first pass cannot know that until the base has been read.
|
|
2935
|
-
const baseDates = new Set(rows.filter((r) => r.calendarId !== calendarId).map((r) => r.date));
|
|
2936
|
-
return [...seen.values()]
|
|
2937
|
-
.map((d) => ({ ...d, overrides: d.fromCalendarId === calendarId && baseDates.has(d.date) }))
|
|
2938
|
-
.sort((a, b) => a.date.localeCompare(b.date));
|
|
2939
|
-
}
|
|
2940
3248
|
/**
|
|
2941
3249
|
* What applying a pack would do — and, just as importantly, what it would leave alone.
|
|
2942
3250
|
*
|
|
@@ -3072,154 +3380,75 @@ export function implement_(kernel) {
|
|
|
3072
3380
|
.limit(1);
|
|
3073
3381
|
return row;
|
|
3074
3382
|
}
|
|
3075
|
-
async function loadRequest(tx, workspaceId, requestId) {
|
|
3076
|
-
const [row] = await tx
|
|
3077
|
-
.select()
|
|
3078
|
-
.from(leaveRequests)
|
|
3079
|
-
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
|
|
3080
|
-
.limit(1);
|
|
3081
|
-
if (!row)
|
|
3082
|
-
throw KernError.notFound('Leave request');
|
|
3083
|
-
return row;
|
|
3084
|
-
}
|
|
3085
3383
|
/**
|
|
3086
|
-
*
|
|
3384
|
+
* The Kern accounts behind a set of people.
|
|
3087
3385
|
*
|
|
3088
|
-
*
|
|
3089
|
-
*
|
|
3090
|
-
*
|
|
3386
|
+
* An employee need not have an account, and one removed from the workspace has had the link
|
|
3387
|
+
* cleared on purpose by the `core.member.removed` subscription. Both are "nothing to deliver",
|
|
3388
|
+
* not an error — the same rule `sweepTimeouts` applies to the people it has to reach.
|
|
3091
3389
|
*/
|
|
3092
|
-
async function
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
.
|
|
3098
|
-
.
|
|
3099
|
-
|
|
3100
|
-
.limit(1);
|
|
3101
|
-
if (!type)
|
|
3102
|
-
throw KernError.notFound('Leave type');
|
|
3103
|
-
if (type.archivedAt)
|
|
3104
|
-
blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
|
|
3105
|
-
const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
|
|
3106
|
-
const calendarDaysInRange = resolution.calendarId
|
|
3107
|
-
? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
|
|
3108
|
-
: [];
|
|
3109
|
-
const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
|
|
3110
|
-
? calendarDaysInRange.map((d) => ({
|
|
3111
|
-
date: d.date,
|
|
3112
|
-
name: d.name,
|
|
3113
|
-
workingFraction: d.workingFraction,
|
|
3114
|
-
}))
|
|
3115
|
-
: []);
|
|
3116
|
-
// Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
|
|
3117
|
-
// holiday still costs nothing rather than costing half of nothing.
|
|
3118
|
-
const days = results.map((r) => {
|
|
3119
|
-
let fraction = r.fraction;
|
|
3120
|
-
if (r.date === input.startsOn && input.startPart === 'afternoon')
|
|
3121
|
-
fraction = Math.min(fraction, 0.5);
|
|
3122
|
-
if (r.date === input.endsOn && input.endPart === 'morning')
|
|
3123
|
-
fraction = Math.min(fraction, 0.5);
|
|
3124
|
-
return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
|
|
3125
|
-
});
|
|
3126
|
-
const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
|
|
3127
|
-
const minutes = type.unit === 'hour' && input.hours
|
|
3128
|
-
? Math.round(input.hours * 60)
|
|
3129
|
-
: Math.round(workingDaysTotal * MINUTES_PER_DAY);
|
|
3130
|
-
if (minutes <= 0)
|
|
3131
|
-
blockers.push({
|
|
3132
|
-
code: 'empty',
|
|
3133
|
-
message: 'That range contains no working days.',
|
|
3134
|
-
});
|
|
3135
|
-
const year = yearOf(input.startsOn);
|
|
3136
|
-
const balances = await ledger.balances(tx, workspaceId, personId, year);
|
|
3137
|
-
const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
|
|
3138
|
-
const before = balance?.availableMinutes ?? 0;
|
|
3139
|
-
const after = before - minutes;
|
|
3140
|
-
if (after < 0 && !type.allowNegative)
|
|
3141
|
-
blockers.push({
|
|
3142
|
-
code: 'insufficient',
|
|
3143
|
-
message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
|
|
3144
|
-
});
|
|
3145
|
-
if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
|
|
3146
|
-
blockers.push({
|
|
3147
|
-
code: 'below_floor',
|
|
3148
|
-
message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
|
|
3149
|
-
});
|
|
3150
|
-
// Overlap is refused by a unique index as well; checking here turns a constraint violation into
|
|
3151
|
-
// a sentence naming the dates.
|
|
3152
|
-
const counted = days.filter((d) => d.counted).map((d) => d.date);
|
|
3153
|
-
if (counted.length) {
|
|
3154
|
-
const clash = await tx
|
|
3155
|
-
.select({ date: leaveRequestDays.date })
|
|
3156
|
-
.from(leaveRequestDays)
|
|
3157
|
-
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
|
|
3158
|
-
.limit(3);
|
|
3159
|
-
if (clash.length)
|
|
3160
|
-
blockers.push({
|
|
3161
|
-
code: 'overlap',
|
|
3162
|
-
message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
|
|
3163
|
-
});
|
|
3164
|
-
}
|
|
3165
|
-
if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
|
|
3166
|
-
blockers.push({
|
|
3167
|
-
code: 'document_required',
|
|
3168
|
-
message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
|
|
3169
|
-
});
|
|
3170
|
-
return {
|
|
3171
|
-
workingDays: workingDaysTotal,
|
|
3172
|
-
minutes,
|
|
3173
|
-
days,
|
|
3174
|
-
balanceBeforeMinutes: before,
|
|
3175
|
-
balanceAfterMinutes: after,
|
|
3176
|
-
blockers,
|
|
3177
|
-
};
|
|
3390
|
+
async function accountsOf(tx, workspaceId, personIds) {
|
|
3391
|
+
if (!personIds.length)
|
|
3392
|
+
return [];
|
|
3393
|
+
const rows = await tx
|
|
3394
|
+
.select({ userId: people.userId })
|
|
3395
|
+
.from(people)
|
|
3396
|
+
.where(and(eq(people.workspaceId, workspaceId), inArray(people.id, personIds)));
|
|
3397
|
+
return [...new Set(rows.map((r) => r.userId).filter((id) => !!id))];
|
|
3178
3398
|
}
|
|
3179
3399
|
/**
|
|
3180
|
-
*
|
|
3400
|
+
* Tell the people a newly raised request is waiting on.
|
|
3181
3401
|
*
|
|
3182
|
-
*
|
|
3183
|
-
*
|
|
3184
|
-
*
|
|
3402
|
+
* `hr.approval.requested` has always been *emitted*, and an event is not a notification: nothing
|
|
3403
|
+
* subscribes to it, so the first thing an approver ever heard about a request was the timeout
|
|
3404
|
+
* sweep reminding them about something they had never been told about in the first place.
|
|
3405
|
+
*
|
|
3406
|
+
* The route is the sweep's own, deliberately and to the letter: `core.notifications.create`, the
|
|
3407
|
+
* same `groupKey` so one request stays one card however often it is later reminded about, the
|
|
3408
|
+
* same `url`, and a catch per notification — by the time this runs the request is committed, so a
|
|
3409
|
+
* notification that fails must not become an error for the person who filed it. What they would
|
|
3410
|
+
* lose is a card; what a throw would cost them is the request.
|
|
3411
|
+
*
|
|
3412
|
+
* **After the transaction, never inside it.** Core writes on its own connection, so a
|
|
3413
|
+
* notification sent inside a transaction that then rolls back has already been delivered, and an
|
|
3414
|
+
* approver is holding a card for a request that does not exist.
|
|
3415
|
+
*
|
|
3416
|
+
* No sentence is composed here beyond the English fallback, for the reason the sweep gives: a
|
|
3417
|
+
* title built on the server is built before anyone knows who will read it, so it can only ever be
|
|
3418
|
+
* English on a Persian screen. `data` carries the subject type and the request's own
|
|
3419
|
+
* `summaryParams`, which is what a localised renderer needs to write the sentence itself.
|
|
3185
3420
|
*/
|
|
3186
|
-
async function
|
|
3187
|
-
const
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
minutes: sim.minutes,
|
|
3218
|
-
workingDays: String(sim.workingDays),
|
|
3219
|
-
decidedAt: new Date(),
|
|
3220
|
-
updatedAt: new Date(),
|
|
3221
|
-
})
|
|
3222
|
-
.where(eq(leaveRequests.id, request.id));
|
|
3421
|
+
async function notifyApprovers(notice) {
|
|
3422
|
+
for (const userId of notice.userIds)
|
|
3423
|
+
try {
|
|
3424
|
+
await kernel.call('core.notifications.create', {
|
|
3425
|
+
userId,
|
|
3426
|
+
workspaceId: notice.workspaceId,
|
|
3427
|
+
module: MODULE_ID,
|
|
3428
|
+
type: 'hr.approval.requested',
|
|
3429
|
+
title: 'Your approval is requested',
|
|
3430
|
+
body: notice.summary || null,
|
|
3431
|
+
object: null,
|
|
3432
|
+
url: '/hr/approvals',
|
|
3433
|
+
data: {
|
|
3434
|
+
subjectType: notice.subjectType,
|
|
3435
|
+
requestId: notice.requestId,
|
|
3436
|
+
params: notice.summaryParams ?? {},
|
|
3437
|
+
},
|
|
3438
|
+
groupKey: `hr.approval:${notice.requestId}`,
|
|
3439
|
+
// Whoever filed it, which is not always the person it is about — HR files leave for
|
|
3440
|
+
// somebody often enough that `requestedBy` exists as its own column.
|
|
3441
|
+
actorId: notice.actorId,
|
|
3442
|
+
}, kernel.system);
|
|
3443
|
+
}
|
|
3444
|
+
catch (err) {
|
|
3445
|
+
kernel.log.warn({
|
|
3446
|
+
module: 'hr',
|
|
3447
|
+
workspaceId: notice.workspaceId,
|
|
3448
|
+
requestId: notice.requestId,
|
|
3449
|
+
err: err.message,
|
|
3450
|
+
}, 'approval notification not delivered');
|
|
3451
|
+
}
|
|
3223
3452
|
}
|
|
3224
3453
|
async function clearDefaultChain(tx, workspaceId, subjectType) {
|
|
3225
3454
|
await tx
|
|
@@ -3275,24 +3504,6 @@ export function implement_(kernel) {
|
|
|
3275
3504
|
})),
|
|
3276
3505
|
};
|
|
3277
3506
|
}
|
|
3278
|
-
/**
|
|
3279
|
-
* Everything a punch needs about a person: their zone, and the schedule that shapes their day.
|
|
3280
|
-
*
|
|
3281
|
-
* The zone comes from the resolution ladder — their primary office unless they have an override —
|
|
3282
|
-
* so a punch made on a business trip still counts towards the month they are employed in.
|
|
3283
|
-
*
|
|
3284
|
-
* Everything here is resolved **as of today**, which is what a punch is about. It is therefore
|
|
3285
|
-
* not the place to answer a question about a past date: this used to hand out today's legal
|
|
3286
|
-
* entity as well, and three callers applied it to business dates months back — so a person who
|
|
3287
|
-
* transferred entity had a filed month recomputed against the one they are in now. `recomputeDay`
|
|
3288
|
-
* asks that question of the day it is rebuilding.
|
|
3289
|
-
*/
|
|
3290
|
-
async function personContext(tx, workspaceId, personId) {
|
|
3291
|
-
const today = todayIso();
|
|
3292
|
-
const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
|
|
3293
|
-
const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
|
|
3294
|
-
return { timezone: resolution.timezone, schedule, resolution };
|
|
3295
|
-
}
|
|
3296
3507
|
/**
|
|
3297
3508
|
* The same, plus the one decision every clock procedure has to agree about: which shift *this
|
|
3298
3509
|
* instant* belongs to, and what is already filed on it.
|
|
@@ -3389,43 +3600,6 @@ export function implement_(kernel) {
|
|
|
3389
3600
|
await changed(input.workspaceId, 'attendance_day', row.personId, 'updated');
|
|
3390
3601
|
return toPunch(row);
|
|
3391
3602
|
}
|
|
3392
|
-
/**
|
|
3393
|
-
* Apply an approved correction: write the proposed punches, void what they replace, rebuild.
|
|
3394
|
-
*
|
|
3395
|
-
* Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
|
|
3396
|
-
* so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
|
|
3397
|
-
* regularization exists rather than an update statement.
|
|
3398
|
-
*/
|
|
3399
|
-
async function applyRegularization(tx, workspaceId, regularizationId) {
|
|
3400
|
-
const [row] = await tx
|
|
3401
|
-
.select()
|
|
3402
|
-
.from(regularizations)
|
|
3403
|
-
.where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
|
|
3404
|
-
.limit(1);
|
|
3405
|
-
if (!row || row.status === 'approved')
|
|
3406
|
-
return;
|
|
3407
|
-
if (row.punchId)
|
|
3408
|
-
await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
|
|
3409
|
-
const { timezone, schedule } = await personContext(tx, workspaceId, row.personId);
|
|
3410
|
-
for (const proposal of row.proposed)
|
|
3411
|
-
await tx.insert(punches).values({
|
|
3412
|
-
id: uuidv7(),
|
|
3413
|
-
workspaceId,
|
|
3414
|
-
personId: row.personId,
|
|
3415
|
-
direction: proposal.direction,
|
|
3416
|
-
at: new Date(proposal.at),
|
|
3417
|
-
businessDate: row.businessDate,
|
|
3418
|
-
timezone,
|
|
3419
|
-
method: 'manual',
|
|
3420
|
-
trust: 'trusted',
|
|
3421
|
-
note: `Regularization ${row.id}`,
|
|
3422
|
-
});
|
|
3423
|
-
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
3424
|
-
await tx
|
|
3425
|
-
.update(regularizations)
|
|
3426
|
-
.set({ status: 'approved', appliedAt: new Date() })
|
|
3427
|
-
.where(eq(regularizations.id, row.id));
|
|
3428
|
-
}
|
|
3429
3603
|
async function loadPolicy(tx, workspaceId, policyId) {
|
|
3430
3604
|
const [row] = await tx
|
|
3431
3605
|
.select()
|