@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.
Files changed (50) hide show
  1. package/dist/contract/capabilities.d.ts.map +1 -1
  2. package/dist/contract/capabilities.js +45 -0
  3. package/dist/contract/capabilities.js.map +1 -1
  4. package/dist/contract/exports.d.ts +459 -0
  5. package/dist/contract/exports.d.ts.map +1 -0
  6. package/dist/contract/exports.js +331 -0
  7. package/dist/contract/exports.js.map +1 -0
  8. package/dist/contract/index.d.ts +2 -0
  9. package/dist/contract/index.d.ts.map +1 -1
  10. package/dist/contract/index.js +2 -0
  11. package/dist/contract/index.js.map +1 -1
  12. package/dist/contract/permissions.d.ts +10 -2
  13. package/dist/contract/permissions.d.ts.map +1 -1
  14. package/dist/contract/permissions.js +44 -5
  15. package/dist/contract/permissions.js.map +1 -1
  16. package/dist/contract/rosters.d.ts +183 -0
  17. package/dist/contract/rosters.d.ts.map +1 -0
  18. package/dist/contract/rosters.js +138 -0
  19. package/dist/contract/rosters.js.map +1 -0
  20. package/dist/contract/router.d.ts +893 -0
  21. package/dist/contract/router.d.ts.map +1 -1
  22. package/dist/contract/router.js +220 -1
  23. package/dist/contract/router.js.map +1 -1
  24. package/dist/server/router.d.ts +1210 -0
  25. package/dist/server/router.d.ts.map +1 -1
  26. package/dist/server/router.js +578 -1
  27. package/dist/server/router.js.map +1 -1
  28. package/dist/server/schema.d.ts +727 -1
  29. package/dist/server/schema.d.ts.map +1 -1
  30. package/dist/server/schema.js +105 -0
  31. package/dist/server/schema.js.map +1 -1
  32. package/dist/server/services/exports.d.ts +321 -0
  33. package/dist/server/services/exports.d.ts.map +1 -0
  34. package/dist/server/services/exports.js +765 -0
  35. package/dist/server/services/exports.js.map +1 -0
  36. package/dist/server/services/rosters.d.ts +166 -0
  37. package/dist/server/services/rosters.d.ts.map +1 -0
  38. package/dist/server/services/rosters.js +268 -0
  39. package/dist/server/services/rosters.js.map +1 -0
  40. package/migrations/0012_rosters.sql +163 -0
  41. package/migrations/meta/0012_snapshot.json +4847 -0
  42. package/migrations/meta/_journal.json +7 -0
  43. package/package.json +1 -1
  44. package/src/client/messages.ts +13 -0
  45. package/src/contract/capabilities.ts +45 -0
  46. package/src/contract/exports.ts +354 -0
  47. package/src/contract/index.ts +2 -0
  48. package/src/contract/permissions.ts +47 -5
  49. package/src/contract/rosters.ts +156 -0
  50. package/src/contract/router.ts +256 -0
@@ -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,