244 lines
9.3 KiB
TypeScript
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;
|
|
}
|