Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrPayslipService.server.ts
T

244 lines
9.3 KiB
TypeScript

// P17 HR-Hub workflows (AK-P17b) — payslip domain service.
//
// Employees read their own payslips (opening a payslip marks it read AND writes
// a per-read audit event — payslips are confidential). HR uploads payslips
// singly or in bulk; the bulk auto-match itself is a pure function
// (matchPayslipFile in @hivegrid/view-models) the route runs over the resolved
// employee list — this service only persists the confirmed assignments.
// Every tenant access runs inside withTenantRLS().
import { CatalogError, foundationErrors, notify } from "@hivegrid/foundation";
import { logActivity } from "@hivegrid/activity";
import { createAuditEvent, createAuditEventInExistingTransaction } from "@hivegrid/db/audit-events.server";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { HrServiceError } from "./hrService.server";
const HR_AUDIT_MODULE = "hr";
export type PayslipRow = {
id: string;
tenantId: string;
employeeUserId: string;
uploadedByUserId: string;
periodYear: number;
periodMonth: number;
fileId: string;
isRead: boolean;
readAt: Date | null;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
function assertPeriod(year: number, month: number): void {
if (!Number.isInteger(year) || year < 2000 || year > 2100) throw new HrServiceError("hr.error.payslipPeriodInvalid");
if (!Number.isInteger(month) || month < 1 || month > 12) throw new HrServiceError("hr.error.payslipPeriodInvalid");
}
/** List the caller's own payslips, newest period first. */
export async function listOwnPayslips(tenantId: string, employeeUserId: string): Promise<PayslipRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.employeePayslip.findMany({ where: { tenantId, employeeUserId, archivedAt: null }, orderBy: [{ periodYear: "desc" }, { periodMonth: "desc" }] }),
);
return rows as PayslipRow[];
}
/** HR view of all payslips (optionally for one employee). */
export async function adminListPayslips(tenantId: string, opts?: { employeeUserId?: string }): Promise<PayslipRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.employeePayslip.findMany({
where: { tenantId, archivedAt: null, ...(opts?.employeeUserId ? { employeeUserId: opts.employeeUserId } : {}) },
orderBy: [{ periodYear: "desc" }, { periodMonth: "desc" }],
}),
);
return rows as PayslipRow[];
}
/** Employees with their personal number, for the bulk auto-match (names are
* resolved separately via resolveUserLabels at the route layer). */
export async function listEmployeesForMatch(tenantId: string): Promise<Array<{ userId: string; personalNumber: string | null }>> {
return withTenantRLS(tenantId, (tx) => tx.hrEmployeeProfile.findMany({ where: { tenantId }, select: { userId: true, personalNumber: true } }));
}
async function getPayslipOrThrow(tenantId: string, payslipId: string): Promise<PayslipRow> {
const row = await withTenantRLS(tenantId, (tx) => tx.employeePayslip.findFirst({ where: { id: payslipId, tenantId, archivedAt: null } }));
if (!row) throw new CatalogError(foundationErrors.notFound, { payslipId });
return row as PayslipRow;
}
/**
* Resolve the File id of a payslip the caller may open (their own, or any if
* `canReadAll`), marking it read on first open and writing a per-read audit
* event. Returns the fileId for the route to stream.
*/
export async function openPayslip(input: { tenantId: string; payslipId: string; userId: string; canReadAll: boolean }): Promise<string> {
const row = await getPayslipOrThrow(input.tenantId, input.payslipId);
if (row.employeeUserId !== input.userId && !input.canReadAll) {
throw new CatalogError(foundationErrors.notFound, { payslipId: input.payslipId });
}
if (!row.isRead && row.employeeUserId === input.userId) {
await withTenantRLS(input.tenantId, (tx) => tx.employeePayslip.update({ where: { id: row.id }, data: { isRead: true, readAt: new Date() } }));
}
await createAuditEvent({
tenantId: input.tenantId,
actorId: input.userId,
actorType: "user",
action: "HR_PAYSLIP_READ",
targetType: "EmployeePayslip",
targetId: row.id,
sourceModule: HR_AUDIT_MODULE,
dataSensitivity: "confidential",
outcome: "success",
metadata: { period: `${row.periodYear}-${String(row.periodMonth).padStart(2, "0")}` },
});
return row.fileId;
}
export type CreatePayslipInput = {
tenantId: string;
employeeUserId: string;
uploadedByUserId: string;
periodYear: number;
periodMonth: number;
fileId: string;
isDemoData?: boolean;
};
/** Create (or replace) one payslip for an employee + period. Audited. */
export async function createPayslip(input: CreatePayslipInput): Promise<PayslipRow> {
assertPeriod(input.periodYear, input.periodMonth);
const created = (await withTenantRLS(input.tenantId, async (tx) => {
const [employee, file, previous] = await Promise.all([
tx.hrEmployeeProfile.findFirst({
where: { tenantId: input.tenantId, userId: input.employeeUserId },
select: { userId: true },
}),
tx.file.findFirst({
where: { tenantId: input.tenantId, id: input.fileId, archivedAt: null },
select: { id: true },
}),
tx.employeePayslip.findUnique({
where: {
tenantId_employeeUserId_periodYear_periodMonth: {
tenantId: input.tenantId,
employeeUserId: input.employeeUserId,
periodYear: input.periodYear,
periodMonth: input.periodMonth,
},
},
select: { fileId: true },
}),
]);
if (!employee || !file) {
throw new CatalogError(foundationErrors.notFound);
}
const payslip = await tx.employeePayslip.upsert({
where: { tenantId_employeeUserId_periodYear_periodMonth: { tenantId: input.tenantId, employeeUserId: input.employeeUserId, periodYear: input.periodYear, periodMonth: input.periodMonth } },
update: { fileId: input.fileId, uploadedByUserId: input.uploadedByUserId, isRead: false, readAt: null, archivedAt: null },
create: {
tenantId: input.tenantId,
employeeUserId: input.employeeUserId,
uploadedByUserId: input.uploadedByUserId,
periodYear: input.periodYear,
periodMonth: input.periodMonth,
fileId: input.fileId,
isDemoData: input.isDemoData ?? false,
},
});
if (previous?.fileId && previous.fileId !== input.fileId) {
await tx.fileEntityLink.deleteMany({
where: {
tenantId: input.tenantId,
fileId: previous.fileId,
entityType: "user",
entityId: input.employeeUserId,
linkRole: "hr_payslip",
},
});
}
await tx.fileEntityLink.upsert({
where: {
fileId_entityType_entityId: {
fileId: input.fileId,
entityType: "user",
entityId: input.employeeUserId,
},
},
update: { linkRole: "hr_payslip" },
create: {
tenantId: input.tenantId,
createdByUserId: input.uploadedByUserId,
fileId: input.fileId,
entityType: "user",
entityId: input.employeeUserId,
linkRole: "hr_payslip",
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.uploadedByUserId,
actorKind: "user",
activityType: "file_uploaded",
subjectType: "tenant",
subjectId: input.tenantId,
summaryText: "activity.runtime.hr.payslipProvided",
metadata: {
payslipId: payslip.id,
employeeUserId: input.employeeUserId,
period: `${input.periodYear}-${String(input.periodMonth).padStart(2, "0")}`,
},
},
{ client: tx },
);
await notify(
{
tenantId: input.tenantId,
userId: input.employeeUserId,
channel: "IN_APP",
category: "hr",
messageKey: "hr.notify.payslip.uploaded",
sourceKey: `hr-payslip:${payslip.id}:${payslip.updatedAt.toISOString()}`,
},
tx as unknown as Parameters<typeof notify>[1],
).catch(() => undefined);
await createAuditEventInExistingTransaction(
{
tenantId: input.tenantId,
actorId: input.uploadedByUserId,
actorType: "user",
action: "HR_PAYSLIP_UPLOAD",
targetType: "EmployeePayslip",
targetId: payslip.id,
sourceModule: HR_AUDIT_MODULE,
outcome: "success",
metadata: { employeeUserId: input.employeeUserId },
},
tx as unknown as Parameters<typeof createAuditEventInExistingTransaction>[1],
);
return payslip;
})) as PayslipRow;
return created;
}
export type BulkAssignment = { employeeUserId: string; periodYear: number; periodMonth: number; fileId: string };
/** Persist confirmed bulk assignments (one payslip per assignment). Returns the
* count created. The match preview itself is computed by matchPayslipFile in the
* route before the admin confirms. */
export async function confirmBulkAssignments(input: { tenantId: string; uploadedByUserId: string; assignments: readonly BulkAssignment[] }): Promise<number> {
let count = 0;
for (const assignment of input.assignments) {
await createPayslip({
tenantId: input.tenantId,
employeeUserId: assignment.employeeUserId,
uploadedByUserId: input.uploadedByUserId,
periodYear: assignment.periodYear,
periodMonth: assignment.periodMonth,
fileId: assignment.fileId,
});
count += 1;
}
return count;
}