@kernhq/module-hr 0.18.1 → 0.20.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 +45 -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 +2 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +2 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/permissions.d.ts +10 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +44 -5
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/rosters.d.ts +183 -0
- package/dist/contract/rosters.d.ts.map +1 -0
- package/dist/contract/rosters.js +138 -0
- package/dist/contract/rosters.js.map +1 -0
- package/dist/contract/router.d.ts +893 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +220 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +1210 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +578 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +727 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +105 -0
- package/dist/server/schema.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/rosters.d.ts +166 -0
- package/dist/server/services/rosters.d.ts.map +1 -0
- package/dist/server/services/rosters.js +268 -0
- package/dist/server/services/rosters.js.map +1 -0
- package/migrations/0012_rosters.sql +163 -0
- package/migrations/meta/0012_snapshot.json +4847 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/messages.ts +13 -0
- package/src/contract/capabilities.ts +45 -0
- package/src/contract/exports.ts +354 -0
- package/src/contract/index.ts +2 -0
- package/src/contract/permissions.ts +47 -5
- package/src/contract/rosters.ts +156 -0
- package/src/contract/router.ts +256 -0
package/dist/server/router.js
CHANGED
|
@@ -6,18 +6,20 @@ import { AccrualConfig, CarryForwardConfig, OvertimeConfig, RoundingConfig, Work
|
|
|
6
6
|
import { accrueForPeriod } from '../policy/accrual.js';
|
|
7
7
|
import { countWorkingDays, datesBetween, workingDays } from '../policy/calendar.js';
|
|
8
8
|
import { COUNTRY_PACKS, packDays, packFor } from './packs/index.js';
|
|
9
|
-
import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, attendanceDays, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, periods, personDocuments, personHistory, policies, policyAssignments, positions, punches, regularizations, scheduleAssignments, schedules, } from './schema.js';
|
|
9
|
+
import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, attendanceDays, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, periods, personDocuments, personHistory, policies, policyAssignments, positions, punches, regularizations, rosterAssignments, rosterOverrides, rosterPatterns, rosterShifts, scheduleAssignments, schedules, } from './schema.js';
|
|
10
10
|
import { forViewer, HrAccessService, seesRecordOf, visibleSet } from './services/access.js';
|
|
11
11
|
import { ApprovalService } from './services/approvals.js';
|
|
12
12
|
import { AttendanceService } from './services/attendance.js';
|
|
13
13
|
import { accessLogSort, HrAuditService } from './services/audit.js';
|
|
14
14
|
import { inForceOn, todayIso } from './services/db.js';
|
|
15
|
+
import { assembleExport, exportManifest, PayrollExportService, } from './services/exports.js';
|
|
15
16
|
import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
|
|
16
17
|
import { PeopleService } from './services/people.js';
|
|
17
18
|
import { hashConfig, PolicyService } from './services/policies.js';
|
|
18
19
|
import { closingBalance, PrivacyService, RETENTION_CLASSES, stripSensitiveCustom, } from './services/privacy.js';
|
|
19
20
|
import { absenceBasis, absenceSplit, capTotal, expectedDaysFor, mergeFinality, ReportsService, rangeRefusal, ratio, round2, } from './services/reports.js';
|
|
20
21
|
import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
|
|
22
|
+
import { RosterService, rosterRefusal } from './services/rosters.js';
|
|
21
23
|
const os = implement(hrContract).$context();
|
|
22
24
|
/** Shared so the ordinary case — no sensitive custom fields defined — allocates nothing per page. */
|
|
23
25
|
const NO_HIDDEN_FIELDS = new Set();
|
|
@@ -446,6 +448,8 @@ export function implement_(kernel) {
|
|
|
446
448
|
const approvals = new ApprovalService(kernel, subjects.appliersFor(null));
|
|
447
449
|
const privacy = new PrivacyService();
|
|
448
450
|
const reports = new ReportsService(resolve);
|
|
451
|
+
const rosters = new RosterService();
|
|
452
|
+
const payroll = new PayrollExportService(reports);
|
|
449
453
|
const audit = new HrAuditService(kernel, access);
|
|
450
454
|
const db = kernel.database;
|
|
451
455
|
const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
|
|
@@ -2420,6 +2424,411 @@ export function implement_(kernel) {
|
|
|
2420
2424
|
}),
|
|
2421
2425
|
},
|
|
2422
2426
|
},
|
|
2427
|
+
// ================================================================= rosters
|
|
2428
|
+
rosters: {
|
|
2429
|
+
shifts: {
|
|
2430
|
+
list: scoped.rosters.shifts.list
|
|
2431
|
+
.use(cap('rosters'))
|
|
2432
|
+
.use(requires('hr.attendance.view'))
|
|
2433
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2434
|
+
const where = [eq(rosterShifts.workspaceId, input.workspaceId)];
|
|
2435
|
+
if (!input.includeArchived)
|
|
2436
|
+
where.push(isNull(rosterShifts.archivedAt));
|
|
2437
|
+
const rows = await tx
|
|
2438
|
+
.select()
|
|
2439
|
+
.from(rosterShifts)
|
|
2440
|
+
.where(and(...where))
|
|
2441
|
+
.orderBy(asc(rosterShifts.startTime), asc(rosterShifts.name));
|
|
2442
|
+
return rows.map(toRosterShift);
|
|
2443
|
+
})),
|
|
2444
|
+
create: scoped.rosters.shifts.create
|
|
2445
|
+
.use(cap('rosters'))
|
|
2446
|
+
.use(requires('hr.attendance.manage'))
|
|
2447
|
+
.handler(async ({ input }) => {
|
|
2448
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2449
|
+
const [created] = await tx
|
|
2450
|
+
.insert(rosterShifts)
|
|
2451
|
+
.values({
|
|
2452
|
+
id: uuidv7(),
|
|
2453
|
+
workspaceId: input.workspaceId,
|
|
2454
|
+
name: input.name,
|
|
2455
|
+
code: input.code ?? null,
|
|
2456
|
+
startTime: input.start,
|
|
2457
|
+
endTime: input.end,
|
|
2458
|
+
breakMinutes: input.breakMinutes,
|
|
2459
|
+
graceInMinutes: input.graceInMinutes,
|
|
2460
|
+
graceOutMinutes: input.graceOutMinutes,
|
|
2461
|
+
color: input.color ?? null,
|
|
2462
|
+
})
|
|
2463
|
+
.returning();
|
|
2464
|
+
return created;
|
|
2465
|
+
});
|
|
2466
|
+
await changed(input.workspaceId, 'roster_shift', row.id, 'created');
|
|
2467
|
+
return toRosterShift(row);
|
|
2468
|
+
}),
|
|
2469
|
+
update: scoped.rosters.shifts.update
|
|
2470
|
+
.use(cap('rosters'))
|
|
2471
|
+
.use(requires('hr.attendance.manage'))
|
|
2472
|
+
.handler(async ({ input }) => {
|
|
2473
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2474
|
+
// Written out rather than looped over the patch, because two of the fields are named
|
|
2475
|
+
// differently in the contract and in the table: `start`/`end` read well on a shift and
|
|
2476
|
+
// are `start_time`/`end_time` in SQL, where `end` is a keyword.
|
|
2477
|
+
const set = { updatedAt: new Date() };
|
|
2478
|
+
if (input.name !== undefined)
|
|
2479
|
+
set.name = input.name;
|
|
2480
|
+
if (input.code !== undefined)
|
|
2481
|
+
set.code = input.code ?? null;
|
|
2482
|
+
if (input.start !== undefined)
|
|
2483
|
+
set.startTime = input.start;
|
|
2484
|
+
if (input.end !== undefined)
|
|
2485
|
+
set.endTime = input.end;
|
|
2486
|
+
if (input.breakMinutes !== undefined)
|
|
2487
|
+
set.breakMinutes = input.breakMinutes;
|
|
2488
|
+
if (input.graceInMinutes !== undefined)
|
|
2489
|
+
set.graceInMinutes = input.graceInMinutes;
|
|
2490
|
+
if (input.graceOutMinutes !== undefined)
|
|
2491
|
+
set.graceOutMinutes = input.graceOutMinutes;
|
|
2492
|
+
if (input.color !== undefined)
|
|
2493
|
+
set.color = input.color ?? null;
|
|
2494
|
+
const [updated] = await tx
|
|
2495
|
+
.update(rosterShifts)
|
|
2496
|
+
.set(set)
|
|
2497
|
+
.where(and(eq(rosterShifts.workspaceId, input.workspaceId), eq(rosterShifts.id, input.shiftId)))
|
|
2498
|
+
.returning();
|
|
2499
|
+
if (!updated)
|
|
2500
|
+
throw KernError.notFound('Shift');
|
|
2501
|
+
return updated;
|
|
2502
|
+
});
|
|
2503
|
+
await changed(input.workspaceId, 'roster_shift', row.id, 'updated');
|
|
2504
|
+
return toRosterShift(row);
|
|
2505
|
+
}),
|
|
2506
|
+
archive: scoped.rosters.shifts.archive
|
|
2507
|
+
.use(cap('rosters'))
|
|
2508
|
+
.use(requires('hr.attendance.manage'))
|
|
2509
|
+
.handler(async ({ input }) => {
|
|
2510
|
+
// Archived, not deleted. Rotations and stored overrides point at a shift by id, and a
|
|
2511
|
+
// deleted one would empty out every day it has ever appeared on — history included.
|
|
2512
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
2513
|
+
.update(rosterShifts)
|
|
2514
|
+
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
|
2515
|
+
.where(and(eq(rosterShifts.workspaceId, input.workspaceId), eq(rosterShifts.id, input.shiftId))));
|
|
2516
|
+
await changed(input.workspaceId, 'roster_shift', input.shiftId, 'deleted');
|
|
2517
|
+
return { ok: true };
|
|
2518
|
+
}),
|
|
2519
|
+
},
|
|
2520
|
+
patterns: {
|
|
2521
|
+
list: scoped.rosters.patterns.list
|
|
2522
|
+
.use(cap('rosters'))
|
|
2523
|
+
.use(requires('hr.attendance.view'))
|
|
2524
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2525
|
+
const where = [eq(rosterPatterns.workspaceId, input.workspaceId)];
|
|
2526
|
+
if (!input.includeArchived)
|
|
2527
|
+
where.push(isNull(rosterPatterns.archivedAt));
|
|
2528
|
+
const rows = await tx
|
|
2529
|
+
.select()
|
|
2530
|
+
.from(rosterPatterns)
|
|
2531
|
+
.where(and(...where))
|
|
2532
|
+
.orderBy(asc(rosterPatterns.name));
|
|
2533
|
+
return rows.map(toRosterPattern);
|
|
2534
|
+
})),
|
|
2535
|
+
create: scoped.rosters.patterns.create
|
|
2536
|
+
.use(cap('rosters'))
|
|
2537
|
+
.use(requires('hr.attendance.manage'))
|
|
2538
|
+
.handler(async ({ input }) => {
|
|
2539
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2540
|
+
await rosters.assertShiftsExist(tx, input.workspaceId, input.days.flat());
|
|
2541
|
+
const [created] = await tx
|
|
2542
|
+
.insert(rosterPatterns)
|
|
2543
|
+
.values({
|
|
2544
|
+
id: uuidv7(),
|
|
2545
|
+
workspaceId: input.workspaceId,
|
|
2546
|
+
name: input.name,
|
|
2547
|
+
anchorDate: input.anchorDate,
|
|
2548
|
+
days: input.days.map((d) => [...d]),
|
|
2549
|
+
})
|
|
2550
|
+
.returning();
|
|
2551
|
+
return created;
|
|
2552
|
+
});
|
|
2553
|
+
await changed(input.workspaceId, 'roster_pattern', row.id, 'created');
|
|
2554
|
+
return toRosterPattern(row);
|
|
2555
|
+
}),
|
|
2556
|
+
update: scoped.rosters.patterns.update
|
|
2557
|
+
.use(cap('rosters'))
|
|
2558
|
+
.use(requires('hr.attendance.manage'))
|
|
2559
|
+
.handler(async ({ input }) => {
|
|
2560
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2561
|
+
if (input.days)
|
|
2562
|
+
await rosters.assertShiftsExist(tx, input.workspaceId, input.days.flat());
|
|
2563
|
+
const set = { updatedAt: new Date() };
|
|
2564
|
+
if (input.name !== undefined)
|
|
2565
|
+
set.name = input.name;
|
|
2566
|
+
if (input.anchorDate !== undefined)
|
|
2567
|
+
set.anchorDate = input.anchorDate;
|
|
2568
|
+
if (input.days !== undefined)
|
|
2569
|
+
set.days = input.days.map((d) => [...d]);
|
|
2570
|
+
const [updated] = await tx
|
|
2571
|
+
.update(rosterPatterns)
|
|
2572
|
+
.set(set)
|
|
2573
|
+
.where(and(eq(rosterPatterns.workspaceId, input.workspaceId), eq(rosterPatterns.id, input.patternId)))
|
|
2574
|
+
.returning();
|
|
2575
|
+
if (!updated)
|
|
2576
|
+
throw KernError.notFound('Rotation');
|
|
2577
|
+
return updated;
|
|
2578
|
+
});
|
|
2579
|
+
await changed(input.workspaceId, 'roster_pattern', row.id, 'updated');
|
|
2580
|
+
return toRosterPattern(row);
|
|
2581
|
+
}),
|
|
2582
|
+
archive: scoped.rosters.patterns.archive
|
|
2583
|
+
.use(cap('rosters'))
|
|
2584
|
+
.use(requires('hr.attendance.manage'))
|
|
2585
|
+
.handler(async ({ input }) => {
|
|
2586
|
+
// Hidden from the pickers, still read by everybody already on it. Archiving a rotation
|
|
2587
|
+
// people are working is not the same as taking them off it, and silently emptying their
|
|
2588
|
+
// roster would be a worse answer than leaving it visible until somebody unassigns them.
|
|
2589
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
2590
|
+
.update(rosterPatterns)
|
|
2591
|
+
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
|
2592
|
+
.where(and(eq(rosterPatterns.workspaceId, input.workspaceId), eq(rosterPatterns.id, input.patternId))));
|
|
2593
|
+
await changed(input.workspaceId, 'roster_pattern', input.patternId, 'deleted');
|
|
2594
|
+
return { ok: true };
|
|
2595
|
+
}),
|
|
2596
|
+
},
|
|
2597
|
+
assignments: scoped.rosters.assignments
|
|
2598
|
+
.use(cap('rosters'))
|
|
2599
|
+
.use(requires('hr.attendance.view_team'))
|
|
2600
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2601
|
+
const where = [eq(rosterAssignments.workspaceId, input.workspaceId)];
|
|
2602
|
+
if (input.personId)
|
|
2603
|
+
where.push(eq(rosterAssignments.personId, input.personId));
|
|
2604
|
+
if (input.patternId)
|
|
2605
|
+
where.push(eq(rosterAssignments.patternId, input.patternId));
|
|
2606
|
+
const rows = await tx
|
|
2607
|
+
.select()
|
|
2608
|
+
.from(rosterAssignments)
|
|
2609
|
+
.where(and(...where))
|
|
2610
|
+
.orderBy(desc(rosterAssignments.effectiveFrom), asc(rosterAssignments.id));
|
|
2611
|
+
return rows.map(toRosterAssignment);
|
|
2612
|
+
})),
|
|
2613
|
+
assign: scoped.rosters.assign
|
|
2614
|
+
.use(cap('rosters'))
|
|
2615
|
+
.use(requires('hr.attendance.manage'))
|
|
2616
|
+
.handler(async ({ input }) => {
|
|
2617
|
+
const rows = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2618
|
+
const [pattern] = await tx
|
|
2619
|
+
.select()
|
|
2620
|
+
.from(rosterPatterns)
|
|
2621
|
+
.where(and(eq(rosterPatterns.workspaceId, input.workspaceId), eq(rosterPatterns.id, input.patternId)))
|
|
2622
|
+
.limit(1);
|
|
2623
|
+
if (!pattern)
|
|
2624
|
+
throw KernError.notFound('Rotation');
|
|
2625
|
+
const personIds = [...new Set(input.personIds)];
|
|
2626
|
+
const known = await tx
|
|
2627
|
+
.select({ id: people.id, displayName: people.displayName })
|
|
2628
|
+
.from(people)
|
|
2629
|
+
.where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, personIds)));
|
|
2630
|
+
if (known.length !== personIds.length)
|
|
2631
|
+
throw KernError.badRequest('This list names somebody who is not in this workspace.');
|
|
2632
|
+
const nameOf = new Map(known.map((p) => [p.id, p.displayName]));
|
|
2633
|
+
// An assignment already running when this one starts is closed the day before, which is
|
|
2634
|
+
// how "which rotation was she on in March" stays answerable.
|
|
2635
|
+
await tx
|
|
2636
|
+
.update(rosterAssignments)
|
|
2637
|
+
.set({ effectiveTo: sql `${input.effectiveFrom}::date - 1` })
|
|
2638
|
+
.where(and(eq(rosterAssignments.workspaceId, input.workspaceId), inArray(rosterAssignments.personId, personIds), lte(rosterAssignments.effectiveFrom, sql `${input.effectiveFrom}::date - 1`), or(isNull(rosterAssignments.effectiveTo), gte(rosterAssignments.effectiveTo, input.effectiveFrom))));
|
|
2639
|
+
// One that starts *later* cannot be trimmed backwards without deleting somebody's plan,
|
|
2640
|
+
// so it is refused by name instead. The exclusion constraint would refuse it too, as
|
|
2641
|
+
// `23P01` — a Postgres error code is not something the person at the screen can act on.
|
|
2642
|
+
const clashWhere = [
|
|
2643
|
+
eq(rosterAssignments.workspaceId, input.workspaceId),
|
|
2644
|
+
inArray(rosterAssignments.personId, personIds),
|
|
2645
|
+
gte(rosterAssignments.effectiveFrom, input.effectiveFrom),
|
|
2646
|
+
];
|
|
2647
|
+
if (input.effectiveTo)
|
|
2648
|
+
clashWhere.push(lte(rosterAssignments.effectiveFrom, input.effectiveTo));
|
|
2649
|
+
const clashes = await tx
|
|
2650
|
+
.select()
|
|
2651
|
+
.from(rosterAssignments)
|
|
2652
|
+
.where(and(...clashWhere))
|
|
2653
|
+
.orderBy(asc(rosterAssignments.effectiveFrom))
|
|
2654
|
+
.limit(3);
|
|
2655
|
+
if (clashes.length) {
|
|
2656
|
+
const first = clashes[0];
|
|
2657
|
+
const who = nameOf.get(first.personId) ?? 'Somebody in this list';
|
|
2658
|
+
throw KernError.badRequest(`${who} already starts a rotation on ${first.effectiveFrom}. End that one first, or give this assignment an end date before it.`);
|
|
2659
|
+
}
|
|
2660
|
+
await tx.insert(rosterAssignments).values(personIds.map((personId) => ({
|
|
2661
|
+
id: uuidv7(),
|
|
2662
|
+
workspaceId: input.workspaceId,
|
|
2663
|
+
personId,
|
|
2664
|
+
patternId: input.patternId,
|
|
2665
|
+
effectiveFrom: input.effectiveFrom,
|
|
2666
|
+
effectiveTo: input.effectiveTo ?? null,
|
|
2667
|
+
cycleOffset: input.cycleOffset,
|
|
2668
|
+
})));
|
|
2669
|
+
return tx
|
|
2670
|
+
.select()
|
|
2671
|
+
.from(rosterAssignments)
|
|
2672
|
+
.where(and(eq(rosterAssignments.workspaceId, input.workspaceId), inArray(rosterAssignments.personId, personIds)))
|
|
2673
|
+
.orderBy(desc(rosterAssignments.effectiveFrom), asc(rosterAssignments.id));
|
|
2674
|
+
});
|
|
2675
|
+
await changed(input.workspaceId, 'roster_assignment', input.patternId, 'updated');
|
|
2676
|
+
return rows.map(toRosterAssignment);
|
|
2677
|
+
}),
|
|
2678
|
+
unassign: scoped.rosters.unassign
|
|
2679
|
+
.use(cap('rosters'))
|
|
2680
|
+
.use(requires('hr.attendance.manage'))
|
|
2681
|
+
.handler(async ({ input }) => {
|
|
2682
|
+
const closed = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2683
|
+
// Only assignments that have actually started by that date. Ending one before it begins
|
|
2684
|
+
// is a deletion wearing an end date, and this procedure never deletes: an assignment
|
|
2685
|
+
// somebody set up for next month is left for them to remove deliberately.
|
|
2686
|
+
const rows = await tx
|
|
2687
|
+
.update(rosterAssignments)
|
|
2688
|
+
.set({ effectiveTo: input.effectiveTo })
|
|
2689
|
+
.where(and(eq(rosterAssignments.workspaceId, input.workspaceId), inArray(rosterAssignments.personId, [...new Set(input.personIds)]), isNull(rosterAssignments.effectiveTo), lte(rosterAssignments.effectiveFrom, input.effectiveTo)))
|
|
2690
|
+
.returning({ id: rosterAssignments.id });
|
|
2691
|
+
return rows.length;
|
|
2692
|
+
});
|
|
2693
|
+
for (const personId of new Set(input.personIds))
|
|
2694
|
+
await changed(input.workspaceId, 'roster_assignment', personId, 'updated');
|
|
2695
|
+
return { closed };
|
|
2696
|
+
}),
|
|
2697
|
+
days: scoped.rosters.days
|
|
2698
|
+
.use(cap('rosters'))
|
|
2699
|
+
.use(requires('hr.attendance.view'))
|
|
2700
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2701
|
+
const personId = await rosterPersonFor(tx, input.workspaceId, context, input.personId);
|
|
2702
|
+
const refusal = rosterRefusal({ from: input.from, to: input.to, coverage: false });
|
|
2703
|
+
if (refusal)
|
|
2704
|
+
throw KernError.badRequest(refusal);
|
|
2705
|
+
const days = await rosters.forPerson(tx, input.workspaceId, personId, input.from, input.to);
|
|
2706
|
+
return days.map((day) => toRosterDay(personId, day));
|
|
2707
|
+
})),
|
|
2708
|
+
set: scoped.rosters.set
|
|
2709
|
+
.use(cap('rosters'))
|
|
2710
|
+
.use(requires('hr.attendance.manage'))
|
|
2711
|
+
.handler(async ({ input, context }) => {
|
|
2712
|
+
const day = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2713
|
+
const [person] = await tx
|
|
2714
|
+
.select({ id: people.id })
|
|
2715
|
+
.from(people)
|
|
2716
|
+
.where(and(eq(people.workspaceId, input.workspaceId), eq(people.id, input.personId)))
|
|
2717
|
+
.limit(1);
|
|
2718
|
+
if (!person)
|
|
2719
|
+
throw KernError.notFound('Employee');
|
|
2720
|
+
await rosters.assertShiftsExist(tx, input.workspaceId, input.shiftIds);
|
|
2721
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
2722
|
+
await tx
|
|
2723
|
+
.insert(rosterOverrides)
|
|
2724
|
+
.values({
|
|
2725
|
+
id: uuidv7(),
|
|
2726
|
+
workspaceId: input.workspaceId,
|
|
2727
|
+
personId: input.personId,
|
|
2728
|
+
businessDate: input.businessDate,
|
|
2729
|
+
shiftIds: [...input.shiftIds],
|
|
2730
|
+
note: input.note ?? null,
|
|
2731
|
+
createdBy: me?.id ?? null,
|
|
2732
|
+
})
|
|
2733
|
+
// One override per person-day, so a second edit of the same Tuesday replaces the
|
|
2734
|
+
// first rather than adding a row nothing would ever choose between.
|
|
2735
|
+
.onConflictDoUpdate({
|
|
2736
|
+
target: [rosterOverrides.workspaceId, rosterOverrides.personId, rosterOverrides.businessDate],
|
|
2737
|
+
set: {
|
|
2738
|
+
shiftIds: [...input.shiftIds],
|
|
2739
|
+
note: input.note ?? null,
|
|
2740
|
+
createdBy: me?.id ?? null,
|
|
2741
|
+
updatedAt: new Date(),
|
|
2742
|
+
},
|
|
2743
|
+
});
|
|
2744
|
+
const [resolved] = await rosters.forPerson(tx, input.workspaceId, input.personId, input.businessDate, input.businessDate);
|
|
2745
|
+
return resolved;
|
|
2746
|
+
});
|
|
2747
|
+
await changed(input.workspaceId, 'roster_day', input.personId, 'updated');
|
|
2748
|
+
return toRosterDay(input.personId, day);
|
|
2749
|
+
}),
|
|
2750
|
+
clear: scoped.rosters.clear
|
|
2751
|
+
.use(cap('rosters'))
|
|
2752
|
+
.use(requires('hr.attendance.manage'))
|
|
2753
|
+
.handler(async ({ input }) => {
|
|
2754
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
2755
|
+
.delete(rosterOverrides)
|
|
2756
|
+
.where(and(eq(rosterOverrides.workspaceId, input.workspaceId), eq(rosterOverrides.personId, input.personId), eq(rosterOverrides.businessDate, input.businessDate))));
|
|
2757
|
+
await changed(input.workspaceId, 'roster_day', input.personId, 'updated');
|
|
2758
|
+
return { ok: true };
|
|
2759
|
+
}),
|
|
2760
|
+
coverage: scoped.rosters.coverage
|
|
2761
|
+
.use(cap('rosters'))
|
|
2762
|
+
.use(requires('hr.attendance.view_team'))
|
|
2763
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2764
|
+
const shape = rosterRefusal({ from: input.from, to: input.to, coverage: true });
|
|
2765
|
+
if (shape)
|
|
2766
|
+
throw KernError.badRequest(shape);
|
|
2767
|
+
// The population is whoever a rotation covers — narrowed to one office when asked. A
|
|
2768
|
+
// grid over people no rotation touches is a page of empty rows.
|
|
2769
|
+
const rostered = await tx
|
|
2770
|
+
.selectDistinct({ personId: rosterAssignments.personId })
|
|
2771
|
+
.from(rosterAssignments)
|
|
2772
|
+
.where(and(eq(rosterAssignments.workspaceId, input.workspaceId), lte(rosterAssignments.effectiveFrom, input.to), or(isNull(rosterAssignments.effectiveTo), gte(rosterAssignments.effectiveTo, input.from))));
|
|
2773
|
+
let personIds = rostered.map((r) => r.personId);
|
|
2774
|
+
if (input.officeId) {
|
|
2775
|
+
const here = await tx
|
|
2776
|
+
.select({ personId: officeAssignments.personId })
|
|
2777
|
+
.from(officeAssignments)
|
|
2778
|
+
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
|
|
2779
|
+
const inOffice = new Set(here.map((h) => h.personId));
|
|
2780
|
+
personIds = personIds.filter((id) => inOffice.has(id));
|
|
2781
|
+
}
|
|
2782
|
+
const dates = datesBetween(input.from, input.to);
|
|
2783
|
+
if (!personIds.length)
|
|
2784
|
+
return dates.map((businessDate) => ({ businessDate, slots: [], off: [] }));
|
|
2785
|
+
const tooWide = rosterRefusal({
|
|
2786
|
+
from: input.from,
|
|
2787
|
+
to: input.to,
|
|
2788
|
+
coverage: true,
|
|
2789
|
+
population: personIds.length,
|
|
2790
|
+
});
|
|
2791
|
+
if (tooWide)
|
|
2792
|
+
throw KernError.badRequest(tooWide);
|
|
2793
|
+
const plan = await rosters.plan(tx, input.workspaceId, personIds, dates);
|
|
2794
|
+
const named = await tx
|
|
2795
|
+
.select({ personId: people.id, displayName: people.displayName })
|
|
2796
|
+
.from(people)
|
|
2797
|
+
.where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, personIds)));
|
|
2798
|
+
const nameOf = new Map(named.map((p) => [p.personId, p.displayName]));
|
|
2799
|
+
const person = (personId) => ({
|
|
2800
|
+
personId,
|
|
2801
|
+
displayName: nameOf.get(personId) ?? '',
|
|
2802
|
+
});
|
|
2803
|
+
return dates.map((businessDate, index) => {
|
|
2804
|
+
const slots = new Map();
|
|
2805
|
+
const off = [];
|
|
2806
|
+
for (const personId of personIds) {
|
|
2807
|
+
const day = plan.get(personId)?.[index];
|
|
2808
|
+
// `none` means nothing rosters this person on this date, which is not the same as a
|
|
2809
|
+
// planned day off and does not belong in either column.
|
|
2810
|
+
if (!day || day.source === 'none')
|
|
2811
|
+
continue;
|
|
2812
|
+
if (!day.shifts.length) {
|
|
2813
|
+
off.push(personId);
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2816
|
+
for (const shift of day.shifts) {
|
|
2817
|
+
const slot = slots.get(shift.id) ?? { shift: toRosterShift(shift), people: [] };
|
|
2818
|
+
slot.people.push(personId);
|
|
2819
|
+
slots.set(shift.id, slot);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
return {
|
|
2823
|
+
businessDate,
|
|
2824
|
+
slots: [...slots.values()]
|
|
2825
|
+
.sort((a, b) => a.shift.start.localeCompare(b.shift.start) || a.shift.name.localeCompare(b.shift.name))
|
|
2826
|
+
.map((slot) => ({ shift: slot.shift, people: slot.people.map(person) })),
|
|
2827
|
+
off: off.map(person),
|
|
2828
|
+
};
|
|
2829
|
+
});
|
|
2830
|
+
})),
|
|
2831
|
+
},
|
|
2423
2832
|
// ================================================================= leave
|
|
2424
2833
|
leave: {
|
|
2425
2834
|
types: {
|
|
@@ -3538,6 +3947,92 @@ export function implement_(kernel) {
|
|
|
3538
3947
|
};
|
|
3539
3948
|
})),
|
|
3540
3949
|
},
|
|
3950
|
+
// ================================================================= payroll export
|
|
3951
|
+
/**
|
|
3952
|
+
* The monthly handover to whoever runs payroll, for one legal entity, frozen at v1.
|
|
3953
|
+
*
|
|
3954
|
+
* **Three keys, and the middle one is new.** `hr.payroll.export` ships in this change because
|
|
3955
|
+
* this is the change that makes a writer exist — it is granted to nobody by default, like
|
|
3956
|
+
* `hr.person.view_sensitive` and `hr.privacy.manage`, so on a fresh workspace only an owner can
|
|
3957
|
+
* reach it. On top of it, the same second-check rule the reports follow:
|
|
3958
|
+
* `hr.attendance.view_team` for the hours file and `hr.leave.view_team` for the leave file,
|
|
3959
|
+
* because an export must not answer what the row-level procedure would refuse.
|
|
3960
|
+
*
|
|
3961
|
+
* **One capability gate, and it carries two more inside it.** `payroll_export` declares
|
|
3962
|
+
* `dependsOn: ['core', 'periods', 'attendance']`, and `kernel.capabilities` prunes a capability
|
|
3963
|
+
* whose dependencies are off — so a workspace with attendance off has no day sheet to hand over
|
|
3964
|
+
* and this answers 404, and a workspace with periods off would have every day open, the refusal
|
|
3965
|
+
* below would fire on every call, and a switch that only ever produces an error is worse than no
|
|
3966
|
+
* switch. Gating on the three separately would say the same thing three times and let them drift;
|
|
3967
|
+
* the dependency list is where that belongs.
|
|
3968
|
+
*
|
|
3969
|
+
* That pruning is measured rather than assumed, because the whole gate rests on it: against a real
|
|
3970
|
+
* kernel, `payroll.export.v1` answers `NOT_FOUND` with `attendance` off, with `periods` off, and
|
|
3971
|
+
* with `payroll_export` itself off, and reaches the handler only when all three are on. Worth
|
|
3972
|
+
* knowing when reading a support ticket: all three refusals say *`hr.payroll_export` is not
|
|
3973
|
+
* enabled*, so an administrator looking at a switch that is plainly on is looking at a dependency
|
|
3974
|
+
* that is off.
|
|
3975
|
+
*
|
|
3976
|
+
* **Nothing here writes**, including no `sensitive_access_log` row: this export reads no
|
|
3977
|
+
* sensitive field. Adding `iban` would make it a bulk decrypt of every employee's bank account
|
|
3978
|
+
* and would owe one audit row per person with `via: 'export'` — a different procedure with its
|
|
3979
|
+
* own key, not a column on this one.
|
|
3980
|
+
*/
|
|
3981
|
+
payroll: {
|
|
3982
|
+
export: {
|
|
3983
|
+
/**
|
|
3984
|
+
* One entity, one period, two CSVs and a manifest.
|
|
3985
|
+
*
|
|
3986
|
+
* Synchronous, and that is checked rather than preferred: `core.files.createUpload` needs a
|
|
3987
|
+
* user principal and returns a presigned PUT for a browser, so a background job cannot mint a
|
|
3988
|
+
* `FileObject` at all, and writing bytes straight into `kernel.storage` would orphan an
|
|
3989
|
+
* object `core.files.*` cannot see and nothing will ever delete.
|
|
3990
|
+
*
|
|
3991
|
+
* It throws the first refusal `collect` found rather than emitting a row of zeros — an open
|
|
3992
|
+
* period without `draft`, an entity with nobody in it, or somebody with no employment record
|
|
3993
|
+
* covering their days here. The preview below returns all of them instead, so a screen can
|
|
3994
|
+
* show the reader every reason at once before anybody downloads anything.
|
|
3995
|
+
*/
|
|
3996
|
+
v1: scoped.payroll.export.v1
|
|
3997
|
+
.use(cap('payroll_export'))
|
|
3998
|
+
.use(requires('hr.payroll.export'))
|
|
3999
|
+
.use(requires('hr.attendance.view_team'))
|
|
4000
|
+
.use(requires('hr.leave.view_team'))
|
|
4001
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
4002
|
+
const data = await payroll.collect(tx, input);
|
|
4003
|
+
const [first] = data.refusals;
|
|
4004
|
+
// `conflict` rather than `badRequest`: the request is well formed and the state is not
|
|
4005
|
+
// ready. `hr.period.not_locked` mirrors the spelling of `hr.period.locked`, which
|
|
4006
|
+
// `PolicyService.assertOpen` throws pointed the other way.
|
|
4007
|
+
if (first)
|
|
4008
|
+
throw KernError.conflict(first.message, first.code);
|
|
4009
|
+
return assembleExport(payrollAssembly(data, input.draft));
|
|
4010
|
+
})),
|
|
4011
|
+
/**
|
|
4012
|
+
* The same rows as JSON, with no file and no refusal thrown.
|
|
4013
|
+
*
|
|
4014
|
+
* The manifest it carries is the manifest the export *would* write, filenames included, so a
|
|
4015
|
+
* screen can name the files before they exist and a reader can see `DRAFT` in the name before
|
|
4016
|
+
* choosing to send it.
|
|
4017
|
+
*/
|
|
4018
|
+
preview: scoped.payroll.export.preview
|
|
4019
|
+
.use(cap('payroll_export'))
|
|
4020
|
+
.use(requires('hr.payroll.export'))
|
|
4021
|
+
.use(requires('hr.attendance.view_team'))
|
|
4022
|
+
.use(requires('hr.leave.view_team'))
|
|
4023
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
4024
|
+
const data = await payroll.collect(tx, input);
|
|
4025
|
+
return {
|
|
4026
|
+
manifest: exportManifest(payrollAssembly(data, input.draft)),
|
|
4027
|
+
refusals: data.refusals,
|
|
4028
|
+
exportable: data.refusals.length === 0,
|
|
4029
|
+
totals: data.totals,
|
|
4030
|
+
hours: data.hours,
|
|
4031
|
+
leave: data.leave,
|
|
4032
|
+
};
|
|
4033
|
+
})),
|
|
4034
|
+
},
|
|
4035
|
+
},
|
|
3541
4036
|
// ================================================================= privacy
|
|
3542
4037
|
/**
|
|
3543
4038
|
* Subject access, erasure and retention.
|
|
@@ -4036,6 +4531,30 @@ export function implement_(kernel) {
|
|
|
4036
4531
|
});
|
|
4037
4532
|
return personId;
|
|
4038
4533
|
}
|
|
4534
|
+
/**
|
|
4535
|
+
* The same rule as `personFor`, asking for the attendance key rather than the leave one.
|
|
4536
|
+
*
|
|
4537
|
+
* A roster is what somebody is expected to turn up for, which is an attendance fact — reusing
|
|
4538
|
+
* `personFor` here would mean a manager needed permission to read a colleague's *leave balance*
|
|
4539
|
+
* before they could see who is on Tuesday's late shift. Reading your own needs nothing beyond
|
|
4540
|
+
* being an employee, exactly as `attendance.days.list` already decides it.
|
|
4541
|
+
*/
|
|
4542
|
+
async function rosterPersonFor(tx, workspaceId, context, personId) {
|
|
4543
|
+
const me = await svc.byUserId(tx, workspaceId, context.principal.userId ?? '');
|
|
4544
|
+
if (!personId) {
|
|
4545
|
+
if (!me)
|
|
4546
|
+
throw KernError.notFound('Your employee record');
|
|
4547
|
+
return me.id;
|
|
4548
|
+
}
|
|
4549
|
+
if (me && me.id === personId)
|
|
4550
|
+
return personId;
|
|
4551
|
+
await kernel.authz.require(context.principal, 'hr.attendance.view_team', {
|
|
4552
|
+
kind: 'workspace',
|
|
4553
|
+
id: workspaceId,
|
|
4554
|
+
workspaceId,
|
|
4555
|
+
});
|
|
4556
|
+
return personId;
|
|
4557
|
+
}
|
|
4039
4558
|
// ------------------------------------------------------------------ reports
|
|
4040
4559
|
/** What the caller asked to narrow to, refusing a slice with nothing to narrow *to*. */
|
|
4041
4560
|
function sliceOf(input) {
|
|
@@ -4103,6 +4622,34 @@ export function implement_(kernel) {
|
|
|
4103
4622
|
rows,
|
|
4104
4623
|
};
|
|
4105
4624
|
}
|
|
4625
|
+
/**
|
|
4626
|
+
* The provenance half of a payroll export's manifest — the part that is the router's to supply.
|
|
4627
|
+
*
|
|
4628
|
+
* `kernVersion` is the platform version this image was built as, recorded so a file can be traced
|
|
4629
|
+
* back to what wrote it. It is **not** the contract identity: `PAYROLL_EXPORT_CONTRACT` is a
|
|
4630
|
+
* literal that moves only when the column set does, and reading the module version into that field
|
|
4631
|
+
* would rename the format on every patch release.
|
|
4632
|
+
*
|
|
4633
|
+
* The three permissions are the ones the middlewares above actually asked for, written out rather
|
|
4634
|
+
* than inferred, for the reason `ReportScope` exists: two readers must never hold one file's
|
|
4635
|
+
* figures under one title without being told which grants produced them.
|
|
4636
|
+
*/
|
|
4637
|
+
function payrollAssembly(data, draft) {
|
|
4638
|
+
return {
|
|
4639
|
+
entity: data.entity,
|
|
4640
|
+
period: data.period,
|
|
4641
|
+
draft,
|
|
4642
|
+
generatedAt: new Date().toISOString(),
|
|
4643
|
+
kernVersion: kernel.version,
|
|
4644
|
+
permissions: ['hr.payroll.export', 'hr.attendance.view_team', 'hr.leave.view_team'],
|
|
4645
|
+
dayLengthMinutes: MINUTES_PER_DAY,
|
|
4646
|
+
population: data.population,
|
|
4647
|
+
counted: data.counted,
|
|
4648
|
+
attendance: data.attendance,
|
|
4649
|
+
hours: data.hours,
|
|
4650
|
+
leave: data.leave,
|
|
4651
|
+
};
|
|
4652
|
+
}
|
|
4106
4653
|
/** The request a retry is a retry *of*, or undefined the first time a key is seen. */
|
|
4107
4654
|
async function byIdempotencyKey(tx, workspaceId, key) {
|
|
4108
4655
|
const [row] = await tx
|
|
@@ -4611,6 +5158,36 @@ const toScheduleAssignment = (r) => ({
|
|
|
4611
5158
|
...r,
|
|
4612
5159
|
createdAt: r.createdAt.toISOString(),
|
|
4613
5160
|
});
|
|
5161
|
+
/**
|
|
5162
|
+
* `start_time` / `end_time` in the table, `start` / `end` on the wire.
|
|
5163
|
+
*
|
|
5164
|
+
* The column names carry the suffix because `end` is a SQL keyword and a quoted keyword in every
|
|
5165
|
+
* hand-written query is a trap for whoever writes the next one; the contract does not, because a
|
|
5166
|
+
* shift reads as "start 06:00, end 14:00". The spread leaves both spellings on the object and zod
|
|
5167
|
+
* strips the ones the schema does not name.
|
|
5168
|
+
*/
|
|
5169
|
+
const toRosterShift = (r) => ({
|
|
5170
|
+
...r,
|
|
5171
|
+
start: r.startTime,
|
|
5172
|
+
end: r.endTime,
|
|
5173
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
5174
|
+
});
|
|
5175
|
+
const toRosterPattern = (r) => ({
|
|
5176
|
+
...r,
|
|
5177
|
+
days: r.days ?? [],
|
|
5178
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
5179
|
+
});
|
|
5180
|
+
const toRosterAssignment = (r) => ({
|
|
5181
|
+
...r,
|
|
5182
|
+
createdAt: r.createdAt.toISOString(),
|
|
5183
|
+
});
|
|
5184
|
+
const toRosterDay = (personId, day) => ({
|
|
5185
|
+
personId,
|
|
5186
|
+
businessDate: day.businessDate,
|
|
5187
|
+
shifts: day.shifts.map(toRosterShift),
|
|
5188
|
+
source: day.source,
|
|
5189
|
+
note: day.note,
|
|
5190
|
+
});
|
|
4614
5191
|
const toRegularization = (r) => ({
|
|
4615
5192
|
...r,
|
|
4616
5193
|
status: r.status,
|