R47 A1-A8:完成薪资管理模块

这个提交包含在:
2026-03-29 19:05:55 +08:00
父节点 c0384b5bbf
当前提交 7ba72ca87d
共修改 25 个文件,包含 3598 行新增和 4 行删除
+14
查看文件
@@ -250,6 +250,20 @@ export type EmployeeStatsResponse = {
validHolderCount: number;
expiringSoonCount: number;
};
salarySummary: {
currentMonth: {
month: string;
totalNetAmount: number;
averageNetAmount: number;
issuedCount: number;
} | null;
previousMonth: {
month: string;
totalNetAmount: number;
averageNetAmount: number;
issuedCount: number;
} | null;
} | null;
performanceSummary: {
cycle: {
id: string;
+11
查看文件
@@ -83,4 +83,15 @@ export async function exportEmployees(params: {
return apiRequestBlob(`/api/exports/employees?${qs.toString()}`);
}
export async function exportPayslips(params: {
format: "csv" | "xlsx";
month: string;
}) {
const qs = new URLSearchParams();
qs.set("format", params.format);
qs.set("month", params.month);
return apiRequestBlob(`/api/exports/payslips?${qs.toString()}`);
}
export type { ApiBlobResponse };
+188
查看文件
@@ -0,0 +1,188 @@
import { apiRequestData } from "./http";
export type SalaryItemType = "earning" | "deduction";
export type PayslipStatus = "draft" | "issued";
export type SalaryItem = {
id: string;
name: string;
itemType: SalaryItemType;
sortOrder: number;
enabled: boolean;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
};
export type EmployeeSalaryProfileItem = {
salaryItemId: string;
name: string;
itemType: SalaryItemType;
sortOrder: number;
monthlyAmount: number;
};
export type EmployeeSalaryProfileSnapshot = {
effectiveDate: string;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
items: EmployeeSalaryProfileItem[];
};
export type PayslipItem = {
salaryItemId: string;
name: string;
itemType: SalaryItemType;
sortOrder: number;
amount: number;
};
export type Payslip = {
id: string;
employeeId: string;
payMonth: string | null;
status: PayslipStatus;
grossAmount: number;
deductionAmount: number;
netAmount: number;
issuedAt: string | null;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
employee: {
id: string;
employeeNo: string;
name: string;
title: string;
department: {
id: string;
name: string;
} | null;
} | null;
items: PayslipItem[];
};
export type PayrollMonthSummary = {
month: string;
totalNetAmount: number;
averageNetAmount: number;
issuedCount: number;
};
export async function fetchSalaryItems() {
return apiRequestData<{ items: SalaryItem[] }>("/api/payroll/items");
}
export async function createSalaryItem(payload: {
name: string;
itemType: SalaryItemType;
sortOrder: number;
enabled?: boolean;
}) {
return apiRequestData<SalaryItem>("/api/payroll/items", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchSalaryItem(
itemId: string,
payload: Partial<{
name: string;
itemType: SalaryItemType;
sortOrder: number;
enabled: boolean;
}>,
) {
return apiRequestData<SalaryItem>(`/api/payroll/items/${itemId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function fetchEmployeeSalaryProfile(employeeId: string) {
return apiRequestData<{ employeeId: string; snapshots: EmployeeSalaryProfileSnapshot[] }>(
`/api/payroll/employees/${employeeId}/profile`,
);
}
export async function upsertEmployeeSalaryProfile(
employeeId: string,
payload: {
effectiveDate: string;
items: Array<{
salaryItemId: string;
monthlyAmount: number;
}>;
},
) {
return apiRequestData<{ employeeId: string; snapshots: EmployeeSalaryProfileSnapshot[] }>(
`/api/payroll/employees/${employeeId}/profile`,
{
method: "PUT",
body: JSON.stringify(payload),
},
);
}
export async function fetchEmployeePayslips(employeeId: string) {
return apiRequestData<{ employeeId: string; items: Payslip[] }>(`/api/payroll/employees/${employeeId}/payslips`);
}
export async function fetchPayslipsByMonth(month: string) {
return apiRequestData<{
month: string;
salaryItems: SalaryItem[];
items: Payslip[];
summary: {
total: number;
draft: number;
issued: number;
};
}>(`/api/payroll/payslips?month=${encodeURIComponent(month)}`);
}
export async function generatePayslips(month: string) {
return apiRequestData<{
month: string;
summary: {
total: number;
generated: number;
};
}>("/api/payroll/payslips/generate", {
method: "POST",
body: JSON.stringify({ month }),
});
}
export async function patchPayslip(
payslipId: string,
payload: {
items: Array<{
salaryItemId: string;
amount: number;
}>;
},
) {
return apiRequestData<Payslip>(`/api/payroll/payslips/${payslipId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function issuePayslips(month: string) {
return apiRequestData<{
month: string;
summary: {
total: number;
issued: number;
};
}>("/api/payroll/payslips/issue", {
method: "POST",
body: JSON.stringify({ month }),
});
}
@@ -0,0 +1,276 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchEmployeePayslips,
fetchEmployeeSalaryProfile,
fetchSalaryItems,
upsertEmployeeSalaryProfile,
type EmployeeSalaryProfileSnapshot,
type Payslip,
} from "../../api/payroll";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
import { toast } from "sonner";
import { StatusBadge } from "../common/StatusBadge";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
}
function todayDateString() {
return new Date().toISOString().slice(0, 10);
}
function moneyText(value: number) {
return value.toFixed(2);
}
function payslipStatusLabel(status: Payslip["status"]) {
return status === "issued" ? "已发放" : "草稿";
}
type EmployeeSalaryPanelProps = {
employeeId: string;
canManage: boolean;
isSelf: boolean;
};
export function EmployeeSalaryPanel({ employeeId, canManage, isSelf }: EmployeeSalaryPanelProps) {
const queryClient = useQueryClient();
const [effectiveDate, setEffectiveDate] = useState(todayDateString());
const [amountByItemId, setAmountByItemId] = useState<Record<string, string>>({});
const [formInitialized, setFormInitialized] = useState(false);
const salaryItemsQuery = useQuery({
enabled: canManage,
queryKey: ["payroll-items"],
queryFn: fetchSalaryItems,
});
const salaryProfileQuery = useQuery({
enabled: canManage,
queryKey: ["payroll-employee-salary-profile", employeeId],
queryFn: () => fetchEmployeeSalaryProfile(employeeId),
});
const payslipsQuery = useQuery({
enabled: canManage || isSelf,
queryKey: ["payroll-employee-payslips", employeeId],
queryFn: () => fetchEmployeePayslips(employeeId),
});
useEffect(() => {
setEffectiveDate(todayDateString());
setAmountByItemId({});
setFormInitialized(false);
}, [employeeId]);
function applySnapshot(snapshot?: EmployeeSalaryProfileSnapshot | null) {
if (!salaryItemsQuery.data?.items.length) {
return;
}
const nextValues = Object.fromEntries(
salaryItemsQuery.data.items.map((item) => {
const snapshotItem = snapshot?.items.find((entry) => entry.salaryItemId === item.id);
return [item.id, snapshotItem ? moneyText(snapshotItem.monthlyAmount) : "0.00"];
}),
);
setAmountByItemId(nextValues);
setEffectiveDate(snapshot?.effectiveDate ?? todayDateString());
}
useEffect(() => {
if (!canManage || formInitialized || !salaryItemsQuery.data?.items.length) {
return;
}
applySnapshot(salaryProfileQuery.data?.snapshots[0] ?? null);
setFormInitialized(true);
}, [canManage, formInitialized, salaryItemsQuery.data, salaryProfileQuery.data]);
const saveProfileMutation = useMutation({
mutationFn: async () => {
const items = (salaryItemsQuery.data?.items ?? []).map((item) => ({
salaryItemId: item.id,
monthlyAmount: Number(amountByItemId[item.id] || "0"),
}));
return upsertEmployeeSalaryProfile(employeeId, {
effectiveDate,
items,
});
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["payroll-employee-salary-profile", employeeId] }),
queryClient.invalidateQueries({ queryKey: ["payroll-employee-payslips", employeeId] }),
]);
applySnapshot(result.snapshots[0] ?? null);
toast.success("薪资档案已保存");
},
onError: (error) => toast.error(errorMessage(error, "保存薪资档案失败")),
});
const payslips = payslipsQuery.data?.items ?? [];
return (
<div className="space-y-6">
{canManage ? (
<>
<section className="card-std p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-zinc-900">薪资档案</h2>
<p className="mt-1 text-sm text-zinc-500">按生效日期保存整套薪资快照,历史记录保留用于调薪追溯。</p>
</div>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<div>
<h3 className="text-sm font-medium text-zinc-900">历史快照</h3>
<div className="mt-3 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>生效日期</TableHead>
<TableHead>项数</TableHead>
<TableHead>预览</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryProfileQuery.data?.snapshots.length ? (
salaryProfileQuery.data.snapshots.map((snapshot) => (
<TableRow key={snapshot.effectiveDate}>
<TableCell>{snapshot.effectiveDate}</TableCell>
<TableCell>{snapshot.items.length}</TableCell>
<TableCell className="max-w-[240px] text-sm text-zinc-600">
{snapshot.items
.slice(0, 3)
.map((item) => `${item.name} ${moneyText(item.monthlyAmount)}`)
.join(" / ")}
</TableCell>
<TableCell>
<Button type="button" size="sm" variant="secondary" onClick={() => applySnapshot(snapshot)}>
套用
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="py-4 text-sm text-zinc-500">
{salaryProfileQuery.isLoading ? "加载薪资快照中..." : "暂无薪资快照"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="rounded-3xl border border-zinc-200 bg-zinc-50 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-zinc-900">保存薪资快照</h3>
<p className="mt-1 text-xs text-zinc-500">默认带出最近一版快照,可修改为新的生效日期后保存。</p>
</div>
<Button type="button" variant="secondary" onClick={() => applySnapshot(salaryProfileQuery.data?.snapshots[0] ?? null)}>
带出最新快照
</Button>
</div>
<div className="mt-4">
<label className="text-xs text-zinc-500">生效日期</label>
<Input type="date" className="mt-2" value={effectiveDate} onChange={(event) => setEffectiveDate(event.target.value)} />
</div>
<div className="mt-4 space-y-3">
{(salaryItemsQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="grid gap-3 rounded-2xl border border-zinc-200 bg-white px-4 py-3 md:grid-cols-[1fr_100px_160px] md:items-center">
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">{item.itemType === "earning" ? "收入项" : "扣除项"}</div>
</div>
<div className="text-sm text-zinc-500">排序 {item.sortOrder}</div>
<Input
inputMode="decimal"
value={amountByItemId[item.id] ?? "0.00"}
onChange={(event) =>
setAmountByItemId((current) => ({
...current,
[item.id]: event.target.value,
}))
}
/>
</div>
))}
</div>
<div className="mt-5 flex justify-end">
<Button type="button" onClick={() => saveProfileMutation.mutate()} disabled={saveProfileMutation.isPending}>
保存薪资快照
</Button>
</div>
</div>
</div>
</section>
</>
) : null}
<section className="card-std p-6">
<div>
<h2 className="text-lg font-semibold text-zinc-900">{canManage ? "工资条记录" : "已发放工资条"}</h2>
<p className="mt-1 text-sm text-zinc-500">
{canManage ? "管理员可查看该员工全部工资条;员工本人仅可查看已发放工资条。 " : "仅展示已发放工资条,点击展开查看各项明细。"}
</p>
</div>
<div className="mt-6 space-y-3">
{payslips.length ? (
payslips.map((payslip) => (
<details key={payslip.id} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<summary className="cursor-pointer list-none">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-base font-medium text-zinc-900">{payslip.payMonth}</div>
<div className="mt-1 text-sm text-zinc-500">
应发 {moneyText(payslip.grossAmount)} / 应扣 {moneyText(payslip.deductionAmount)} / 实发 {moneyText(payslip.netAmount)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={payslip.status} label={payslipStatusLabel(payslip.status)} />
{payslip.issuedAt ? <div className="text-xs text-zinc-500">发放于 {payslip.issuedAt.slice(0, 10)}</div> : null}
</div>
</div>
</summary>
<div className="mt-4 border-t border-zinc-100 pt-4">
<div className="space-y-2">
{payslip.items.map((item) => (
<div key={item.salaryItemId} className="flex items-center justify-between rounded-2xl border border-zinc-100 bg-zinc-50 px-4 py-3">
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">{item.itemType === "earning" ? "收入项" : "扣除项"}</div>
</div>
<div className="text-lg font-semibold text-zinc-900">{moneyText(item.amount)}</div>
</div>
))}
</div>
</div>
</details>
))
) : (
<div className="text-sm text-zinc-500">
{payslipsQuery.isLoading ? "加载工资条中..." : canManage ? "暂无工资条记录" : "暂无已发放工资条"}
</div>
)}
</div>
</section>
</div>
);
}
+12 -2
查看文件
@@ -36,6 +36,7 @@ import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
import { EmployeeSalaryPanel } from "../components/people/EmployeeSalaryPanel";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -47,7 +48,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { downloadBlob } from "../lib/download";
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance";
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance" | "salary";
type ContractFormState = {
contractNo: string;
@@ -122,7 +123,7 @@ function buildEmptyCertificateForm(): CertificateFormState {
}
function parseProfileTab(value: string | null): ProfileTab {
if (value === "contracts" || value === "trainingCertificates" || value === "performance") {
if (value === "contracts" || value === "trainingCertificates" || value === "performance" || value === "salary") {
return value;
}
return "profile";
@@ -482,6 +483,7 @@ export function EmployeeProfilePage() {
const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data);
const showPerformanceTab =
canManageEmployeeProfile || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
const showSalaryTab = canManageEmployeeProfile || canEditSelfProfile;
const performanceContent =
performanceQuery.isLoading ? (
@@ -498,6 +500,7 @@ export function EmployeeProfilePage() {
]}
/>
);
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManageEmployeeProfile} isSelf={canEditSelfProfile} />;
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
@@ -686,6 +689,7 @@ export function EmployeeProfilePage() {
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
{showPerformanceTab ? <TabsTrigger value="performance">绩效</TabsTrigger> : null}
{showSalaryTab ? <TabsTrigger value="salary">薪资</TabsTrigger> : null}
</TabsList>
<TabsContent value="profile" className="space-y-6">
@@ -1420,6 +1424,12 @@ export function EmployeeProfilePage() {
{performanceContent}
</TabsContent>
) : null}
{showSalaryTab ? (
<TabsContent value="salary" className="space-y-6">
{salaryContent}
</TabsContent>
) : null}
</Tabs>
</div>
);
+558
查看文件
@@ -0,0 +1,558 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createSalaryItem,
fetchPayslipsByMonth,
fetchSalaryItems,
generatePayslips,
issuePayslips,
patchPayslip,
patchSalaryItem,
type Payslip,
type SalaryItem,
type SalaryItemType,
} from "../api/payroll";
import { exportPayslips } from "../api/exports";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { downloadBlob } from "../lib/download";
import { toast } from "sonner";
type SalaryItemFormState = {
name: string;
itemType: SalaryItemType;
sortOrder: string;
enabled: boolean;
};
const SALARY_ITEM_TYPE_OPTIONS: Array<{ value: SalaryItemType; label: string }> = [
{ value: "earning", label: "收入项" },
{ value: "deduction", label: "扣除项" },
];
function buildEmptySalaryItemForm(): SalaryItemFormState {
return {
name: "",
itemType: "earning",
sortOrder: "10",
enabled: true,
};
}
function currentMonthString() {
return new Date().toISOString().slice(0, 7);
}
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
}
function moneyText(value: number) {
return value.toFixed(2);
}
function salaryItemTypeLabel(type: SalaryItemType) {
return SALARY_ITEM_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
}
function payslipStatusLabel(status: Payslip["status"]) {
return status === "issued" ? "已发放" : "草稿";
}
function calculateDraftTotals(items: Payslip["items"], amountByItemId: Record<string, string>) {
let gross = 0;
let deduction = 0;
for (const item of items) {
const value = Number(amountByItemId[item.salaryItemId] ?? item.amount ?? 0);
if (!Number.isFinite(value) || value < 0) {
continue;
}
if (item.itemType === "deduction") {
deduction += value;
} else {
gross += value;
}
}
return {
grossAmount: gross,
deductionAmount: deduction,
netAmount: gross - deduction,
};
}
export function PayrollPage() {
const queryClient = useQueryClient();
const [month, setMonth] = useState(currentMonthString());
const [itemDialogOpen, setItemDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [salaryItemForm, setSalaryItemForm] = useState<SalaryItemFormState>(buildEmptySalaryItemForm());
const [editingPayslipId, setEditingPayslipId] = useState<string | null>(null);
const [payslipAmounts, setPayslipAmounts] = useState<Record<string, string>>({});
const salaryItemsQuery = useQuery({
queryKey: ["payroll-items"],
queryFn: fetchSalaryItems,
});
const monthPayslipsQuery = useQuery({
queryKey: ["payroll-payslips", month],
queryFn: () => fetchPayslipsByMonth(month),
});
const salaryItems = salaryItemsQuery.data?.items ?? [];
const monthPayslips = monthPayslipsQuery.data?.items ?? [];
const editingPayslip = monthPayslips.find((item) => item.id === editingPayslipId) ?? null;
const draftTotals = editingPayslip ? calculateDraftTotals(editingPayslip.items, payslipAmounts) : null;
useEffect(() => {
if (editingPayslipId && !editingPayslip) {
setEditingPayslipId(null);
setPayslipAmounts({});
}
}, [editingPayslip, editingPayslipId]);
function resetSalaryItemDialog() {
setEditingItemId(null);
setSalaryItemForm(buildEmptySalaryItemForm());
setItemDialogOpen(false);
}
function openCreateSalaryItemDialog() {
setEditingItemId(null);
setSalaryItemForm(buildEmptySalaryItemForm());
setItemDialogOpen(true);
}
function openEditSalaryItemDialog(item: SalaryItem) {
setEditingItemId(item.id);
setSalaryItemForm({
name: item.name,
itemType: item.itemType,
sortOrder: String(item.sortOrder),
enabled: item.enabled,
});
setItemDialogOpen(true);
}
function openPayslipEditor(payslip: Payslip) {
setEditingPayslipId(payslip.id);
setPayslipAmounts(
Object.fromEntries(payslip.items.map((item) => [item.salaryItemId, moneyText(item.amount)])),
);
}
function closePayslipEditor() {
setEditingPayslipId(null);
setPayslipAmounts({});
}
async function invalidatePayrollMonth() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["payroll-items"] }),
queryClient.invalidateQueries({ queryKey: ["payroll-payslips", month] }),
queryClient.invalidateQueries({ queryKey: ["employee-stats"] }),
]);
}
const salaryItemMutation = useMutation({
mutationFn: async () => {
const payload = {
name: salaryItemForm.name.trim(),
itemType: salaryItemForm.itemType,
sortOrder: Number(salaryItemForm.sortOrder),
enabled: salaryItemForm.enabled,
};
if (editingItemId) {
return patchSalaryItem(editingItemId, payload);
}
return createSalaryItem(payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["payroll-items"] });
resetSalaryItemDialog();
toast.success(editingItemId ? "薪资项已更新" : "薪资项已创建");
},
onError: (error) => toast.error(errorMessage(error, editingItemId ? "更新薪资项失败" : "创建薪资项失败")),
});
const toggleSalaryItemMutation = useMutation({
mutationFn: ({ itemId, enabled }: { itemId: string; enabled: boolean }) => patchSalaryItem(itemId, { enabled }),
onSuccess: async (_result, variables) => {
await queryClient.invalidateQueries({ queryKey: ["payroll-items"] });
toast.success(variables.enabled ? "薪资项已启用" : "薪资项已停用");
},
onError: (error) => toast.error(errorMessage(error, "更新薪资项状态失败")),
});
const generatePayslipsMutation = useMutation({
mutationFn: () => generatePayslips(month),
onSuccess: async (result) => {
await invalidatePayrollMonth();
toast.success(`已生成 ${result.summary.generated} 张工资条`);
},
onError: (error) => toast.error(errorMessage(error, "生成工资条失败")),
});
const issuePayslipsMutation = useMutation({
mutationFn: () => issuePayslips(month),
onSuccess: async (result) => {
await invalidatePayrollMonth();
toast.success(`已发放 ${result.summary.issued} 张工资条`);
},
onError: (error) => toast.error(errorMessage(error, "发放工资条失败")),
});
const patchPayslipMutation = useMutation({
mutationFn: async () => {
if (!editingPayslip) {
throw new Error("未选择工资条");
}
return patchPayslip(editingPayslip.id, {
items: editingPayslip.items.map((item) => ({
salaryItemId: item.salaryItemId,
amount: Number(payslipAmounts[item.salaryItemId] ?? "0"),
})),
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["payroll-payslips", month] });
closePayslipEditor();
toast.success("工资条已更新");
},
onError: (error) => toast.error(errorMessage(error, "更新工资条失败")),
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") => exportPayslips({ month, format }),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `payslips-${month}.${format}`);
toast.success("工资条导出完成");
},
onError: (error) => toast.error(errorMessage(error, "导出工资条失败")),
});
const monthSummary = monthPayslipsQuery.data?.summary ?? { total: 0, draft: 0, issued: 0 };
return (
<div className="space-y-6">
<PageHeader
title="薪资管理"
subtitle="维护薪资模板、按月生成工资条,并对草稿工资条做逐人调整和发放。"
actions={
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary" type="button">
<Link to="/people">返回人员管理</Link>
</Button>
<Button type="button" onClick={openCreateSalaryItemDialog}>
新建薪资项
</Button>
</div>
}
/>
<section className="card-std p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-zinc-900">薪资模板</h2>
<p className="mt-1 text-sm text-zinc-500">工资条生成会基于当前启用的薪资项模板铺开明细,停用仅影响后续生成。</p>
</div>
<div className="text-sm text-zinc-500">{salaryItems.length} 个薪资项</div>
</div>
<div className="mt-6 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>排序</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryItems.length ? (
salaryItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-zinc-900">{item.name}</TableCell>
<TableCell>{salaryItemTypeLabel(item.itemType)}</TableCell>
<TableCell>{item.sortOrder}</TableCell>
<TableCell>
<StatusBadge status={item.enabled ? "active" : "archived"} label={item.enabled ? "启用中" : "已停用"} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => openEditSalaryItemDialog(item)}>
编辑
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={toggleSalaryItemMutation.isPending}
onClick={() => toggleSalaryItemMutation.mutate({ itemId: item.id, enabled: !item.enabled })}
>
{item.enabled ? "停用" : "启用"}
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-4 text-sm text-zinc-500">
{salaryItemsQuery.isLoading ? "加载薪资项中..." : "暂无薪资项"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<section className="card-std p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-lg font-semibold text-zinc-900">月度工资条</h2>
<p className="mt-1 text-sm text-zinc-500">按月份批量生成草稿,逐人调整后统一发放;已发放工资条不可修改。</p>
</div>
<div className="grid gap-3 md:grid-cols-[180px_repeat(4,minmax(0,1fr))]">
<Input type="month" value={month} onChange={(event) => setMonth(event.target.value)} />
<Button type="button" onClick={() => generatePayslipsMutation.mutate()} disabled={!month || generatePayslipsMutation.isPending}>
{generatePayslipsMutation.isPending ? "生成中..." : "生成工资条"}
</Button>
<Button type="button" variant="secondary" onClick={() => issuePayslipsMutation.mutate()} disabled={!month || issuePayslipsMutation.isPending}>
{issuePayslipsMutation.isPending ? "发放中..." : "批量发放"}
</Button>
<Button type="button" variant="secondary" onClick={() => exportMutation.mutate("csv")} disabled={!month || exportMutation.isPending}>
导出 CSV
</Button>
<Button type="button" variant="secondary" onClick={() => exportMutation.mutate("xlsx")} disabled={!month || exportMutation.isPending}>
导出 Excel
</Button>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm text-zinc-500">当月工资条</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{monthSummary.total}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm text-zinc-500">草稿数</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{monthSummary.draft}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm text-zinc-500">已发放数</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{monthSummary.issued}</div>
</div>
</div>
<div className="mt-6 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>员工</TableHead>
<TableHead>部门</TableHead>
<TableHead>状态</TableHead>
<TableHead>明细预览</TableHead>
<TableHead>应发 / 应扣 / 实发</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthPayslips.length ? (
monthPayslips.map((payslip) => (
<TableRow key={payslip.id}>
<TableCell>
<div className="font-medium text-zinc-900">{payslip.employee?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">{payslip.employee?.employeeNo ?? "-"}</div>
</TableCell>
<TableCell>{payslip.employee?.department?.name ?? "-"}</TableCell>
<TableCell>
<StatusBadge status={payslip.status} label={payslipStatusLabel(payslip.status)} />
</TableCell>
<TableCell className="max-w-[360px] text-sm text-zinc-600">
{payslip.items.length
? payslip.items.map((item) => `${item.name} ${moneyText(item.amount)}`).join(" / ")
: "-"}
</TableCell>
<TableCell className="text-sm text-zinc-700">
{moneyText(payslip.grossAmount)} / {moneyText(payslip.deductionAmount)} / {moneyText(payslip.netAmount)}
</TableCell>
<TableCell>
{payslip.status === "draft" ? (
<Button type="button" size="sm" variant="secondary" onClick={() => openPayslipEditor(payslip)}>
调整明细
</Button>
) : (
<div className="text-xs text-zinc-500">已发放,冻结</div>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-4 text-sm text-zinc-500">
{monthPayslipsQuery.isLoading ? "加载工资条中..." : "该月份暂无工资条,先生成后再调整或发放。"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<Dialog open={itemDialogOpen} onOpenChange={(open) => (!open ? resetSalaryItemDialog() : setItemDialogOpen(true))}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingItemId ? "编辑薪资项" : "新建薪资项"}</DialogTitle>
<DialogDescription>定义收入项或扣除项模板,供员工薪资快照和工资条生成复用。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">名称</label>
<Input value={salaryItemForm.name} onChange={(event) => setSalaryItemForm((current) => ({ ...current, name: event.target.value }))} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">类型</label>
<select
className="select-std w-full"
value={salaryItemForm.itemType}
onChange={(event) =>
setSalaryItemForm((current) => ({
...current,
itemType: event.target.value as SalaryItemType,
}))
}
>
{SALARY_ITEM_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">排序</label>
<Input
type="number"
min="0"
max="999"
value={salaryItemForm.sortOrder}
onChange={(event) => setSalaryItemForm((current) => ({ ...current, sortOrder: event.target.value }))}
/>
</div>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
<input
checked={salaryItemForm.enabled}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
type="checkbox"
onChange={(event) => setSalaryItemForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
保存后启用该薪资项
</label>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={resetSalaryItemDialog}>
取消
</Button>
<Button
type="button"
onClick={() => salaryItemMutation.mutate()}
disabled={!salaryItemForm.name.trim() || !salaryItemForm.sortOrder || salaryItemMutation.isPending}
>
{salaryItemMutation.isPending ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingPayslip)} onOpenChange={(open) => (!open ? closePayslipEditor() : undefined)}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>调整工资条</DialogTitle>
<DialogDescription>
{editingPayslip?.employee?.name ?? "-"} · {editingPayslip?.employee?.employeeNo ?? "-"} · {month}
</DialogDescription>
</DialogHeader>
{editingPayslip ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm text-zinc-500">应发预览</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{moneyText(draftTotals?.grossAmount ?? 0)}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm text-zinc-500">应扣预览</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{moneyText(draftTotals?.deductionAmount ?? 0)}</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm text-zinc-500">实发预览</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{moneyText(draftTotals?.netAmount ?? 0)}</div>
</div>
</div>
<div className="space-y-3">
{editingPayslip.items.map((item) => (
<div key={item.salaryItemId} className="grid gap-3 rounded-2xl border border-zinc-200 bg-white px-4 py-3 md:grid-cols-[1fr_120px_160px] md:items-center">
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">{salaryItemTypeLabel(item.itemType)}</div>
</div>
<div className="text-sm text-zinc-500">当前 {moneyText(item.amount)}</div>
<Input
inputMode="decimal"
value={payslipAmounts[item.salaryItemId] ?? moneyText(item.amount)}
onChange={(event) =>
setPayslipAmounts((current) => ({
...current,
[item.salaryItemId]: event.target.value,
}))
}
/>
</div>
))}
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="secondary" onClick={closePayslipEditor}>
取消
</Button>
<Button type="button" onClick={() => patchPayslipMutation.mutate()} disabled={!editingPayslip || patchPayslipMutation.isPending}>
{patchPayslipMutation.isPending ? "保存中..." : "保存调整"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default PayrollPage;
+5
查看文件
@@ -292,6 +292,11 @@ export function PeoplePage() {
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
actions={
<div className="flex flex-wrap gap-2">
{canManageUsers ? (
<Button asChild variant="secondary" type="button">
<Link to="/payroll">薪资管理</Link>
</Button>
) : null}
{canManageUsers ? (
<Button asChild variant="secondary" type="button">
<Link to="/performance">绩效考核</Link>
+37
查看文件
@@ -16,6 +16,10 @@ function employmentStatusLabel(status: EmployeeEmploymentStatus) {
return EMPLOYMENT_STATUS_LABELS[status] ?? status;
}
function moneyText(value: number | null | undefined) {
return value == null ? "-" : value.toFixed(2);
}
export function PeopleStatsPage() {
const statsQuery = useQuery({
queryKey: ["employee-stats"],
@@ -31,6 +35,7 @@ export function PeopleStatsPage() {
}
const stats = statsQuery.data;
const salarySummary = stats.salarySummary;
return (
<div className="space-y-6">
@@ -83,6 +88,38 @@ export function PeopleStatsPage() {
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
</div>
</section>
{salarySummary ? (
<section className="card-std p-5">
<div className="text-sm text-zinc-500">当月薪资总支出</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.currentMonth?.totalNetAmount)}</div>
<div className="mt-2 text-xs text-zinc-500">
{salarySummary.currentMonth ? `${salarySummary.currentMonth.month} · 已发放 ${salarySummary.currentMonth.issuedCount} 人` : "当月暂无已发放工资条"}
</div>
</section>
) : null}
{salarySummary ? (
<section className="card-std p-5">
<div className="text-sm text-zinc-500">当月人均薪资</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.currentMonth?.averageNetAmount)}</div>
<div className="mt-2 text-xs text-zinc-500">仅统计 issued 工资条实发金额</div>
</section>
) : null}
{salarySummary ? (
<section className="card-std p-5">
<div className="text-sm text-zinc-500">上月薪资总支出</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.totalNetAmount)}</div>
<div className="mt-2 text-xs text-zinc-500">
{salarySummary.previousMonth ? `${salarySummary.previousMonth.month} · 已发放 ${salarySummary.previousMonth.issuedCount} 人` : "上月暂无已发放工资条"}
</div>
</section>
) : null}
{salarySummary ? (
<section className="card-std p-5">
<div className="text-sm text-zinc-500">上月人均薪资</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.averageNetAmount)}</div>
<div className="mt-2 text-xs text-zinc-500">非 system.users 不展示任何薪资汇总</div>
</section>
) : null}
<section className="card-std p-5">
<div className="text-sm text-zinc-500">当前 / 最近周期平均分</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">
+10
查看文件
@@ -32,6 +32,7 @@ import {
NotificationChannelsPage,
NotificationsPage,
OrganizationPage,
PayrollPage,
PerformancePage,
PaymentBatchesPage,
PeoplePage,
@@ -305,6 +306,15 @@ export function AppRouter() {
}
/>
<Route
path="payroll"
element={
<RequirePermission anyOf={["system.users"]}>
{withRouteFallback(<PayrollPage />)}
</RequirePermission>
}
/>
<Route
path="performance"
element={
+1
查看文件
@@ -0,0 +1 @@
export { PayrollPage as default } from "../../pages/PayrollPage";
+1
查看文件
@@ -23,6 +23,7 @@ export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PeoplePage = lazy(() => import("./PeoplePage"));
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
export const PerformancePage = lazy(() => import("./PerformancePage"));
export const PayrollPage = lazy(() => import("./PayrollPage"));
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
+9
查看文件
@@ -0,0 +1,9 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
"A1","R47 执行合同与范围冻结","创建 R47 Issue CSV,冻结范围为薪资模板、员工薪资档案、工资条生成与发放、员工查看、薪资统计与导出、/payroll 管理页;锁定薪资路由独立于 employees.js,薪资统计仅对 system.users 返回。","issues/2026-03-29_r47-payroll.csv 列完整、依赖清晰、测试方法可复现;范围仅覆盖本轮薪资管理核心流程。","对照 AGENTS.md 与 issues/README.md 检查列完整、Dependencies 为 JSON 数组字符串、Test_Method 含命令与业务流程步骤;确认范围不包含外部薪酬系统、银行打款、审批流、个税规则引擎或消息通知。","manual","DONE","DONE","DONE","issues/2026-03-29_r47-payroll.csv","[]","QA 2026-03-29:Issue CSV 已创建并按批准方案冻结范围,依赖与测试口径已落地。"
"A2","R47 薪资模板与员工薪资档案后端","新增 SalaryItem、EmployeeSalary 模型与 payroll 路由基础能力;实现薪资模板管理与员工薪资快照档案 API,档案以 effectiveDate 为整套快照保存,历史保留不删除。","system.users 可创建/编辑/停用薪资模板,可为员工保存指定 effectiveDate 的整套薪资快照;同一 effectiveDate 的快照重复保存时在事务内整套替换;非 system.users 访问或修改薪资模板/薪资档案返回 403。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/payroll.js && node --check src/app.js && npm test -- tests/unit/routes/payroll.items.test.js tests/unit/routes/payroll.salary-profile.test.js。业务:1) 管理员创建 earning/deduction 两类薪资模板成功并可停用;2) 为员工保存一套含多条薪资项的 effectiveDate 快照成功;3) 同一 effectiveDate 再次保存会替换该日快照而不会删除历史日期快照;4) 按员工查询薪资快照历史返回正确;5) 普通员工和 report.dashboard.view 访问薪资模板/薪资档案均返回 403。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/payroll.js|server/src/app.js|server/tests/unit/routes/payroll.items.test.js|server/tests/unit/routes/payroll.salary-profile.test.js","[""A1""]","回滚:先清理 payslip_items / payslips / employee_salaries / salary_items 数据,再回滚 Prisma migration 与 payroll route 注册。本轮不自动 seed 默认薪资模板,列表中的基础工资/津贴/奖金/扣除由管理员通过 UI/API 建立。QA 2026-03-29:Prisma validate、node --check src/routes/payroll.js、vitest payroll.items/payroll.salary-profile 通过,system.users 独占模板与快照权限。"
"A3","R47 工资条生成、调整与发放后端","新增 Payslip、PayslipItem 模型与工资条 API;实现按月批量生成 draft、逐人调整 draft、整月发放 issued,以及员工/管理员工资条查询。","管理员可按 YYYY-MM 为 active 员工批量生成工资条;任一员工缺少目标月生效薪资快照或目标月已存在工资条时整批失败并回滚;draft 可编辑明细且服务端重算应发/应扣/实发;issued 后冻结且员工本人仅能看到 issued。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/payroll.js && npm test -- tests/unit/routes/payroll.payslips.test.js。业务:1) 管理员先创建薪资模板和员工薪资快照,再按指定月份批量生成工资条成功;2) draft 明细可修改绩效奖金/缺勤扣款,gross/deduction/net 由服务端重算;3) 目标月已有工资条时再次生成返回冲突;4) active 员工缺少生效薪资快照时整批生成失败且不落库;5) 发放后状态变为 issued,员工本人查询只能看到 issued,管理员可看到该员工全部月份工资条;6) issued 工资条再次修改返回 409。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/payroll.js|server/tests/unit/routes/payroll.payslips.test.js","[""A2""]","回滚:先清理 payslip_items / payslips 数据,再回滚 Prisma migration 与工资条相关代码。pay_month 统一存为目标月第一天;生成口径按目标月月末生效的最新薪资快照,不做月内比例拆分。QA 2026-03-29:vitest payroll.payslips.test.js 通过;已复核生成与发放均包裹事务、issued 后冻结、员工本人仅能看到 issued。"
"A4","R47 薪资导出与统计后端","在 exports.js 增加指定月份工资条导出,在 employeeStats 中增加 salarySummary;薪资统计仅对 system.users 返回,其他角色拿到 null。","system.users 可导出指定月份工资条 CSV/XLSX,导出写审计日志且列包含工号、姓名、部门、各薪资项、应发、应扣、实发;/api/employees/stats 对 system.users 返回 currentMonth/previousMonth 薪资汇总,对 report.dashboard.view 不返回薪资数据。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/payroll.js && node --check src/routes/exports.js && node --check src/routes/employees.js && node --check src/services/employeeStats.js && npm test -- tests/unit/routes/payroll.exports.test.js tests/unit/routes/employees.stats.test.js。业务:1) 在已有指定月份工资条后导出 CSV/XLSX 成功,列顺序正确且金额与工资条一致;2) 导出审计日志写入 action/月份/匹配数量;3) system.users 请求员工统计可看到当月/上月薪资总支出与人均薪资;4) report.dashboard.view 请求员工统计时 salarySummary 为 null,且无法通过任何 payroll/export 接口获取薪资数据。","manual","DONE","DONE","DONE","server/src/routes/exports.js|server/src/routes/employees.js|server/src/services/employeeStats.js|server/src/routes/payroll.js|server/tests/unit/routes/payroll.exports.test.js|server/tests/unit/routes/employees.stats.test.js","[""A2"",""A3""]","回滚:删除 payslip export 路由与 salarySummary 字段,恢复 employeeStats 原结构。薪资统计仅统计 issued 工资条的 net_amount。QA 2026-03-29:node --check exports/employees/employeeStats 与 vitest payroll.exports/employees.stats 通过;salarySummary 仅对 system.users 返回,导出链路含审计日志。"
"A5","R47 薪资管理页与前端 API","新增 payroll 前端 API、/payroll 页面、PeoplePage 入口和导出接入;管理员可管理薪资模板、按月生成/调整/发放工资条并导出。","system.users 可从 PeoplePage 进入 /payroll;页面可维护薪资模板、选择月份生成工资条、编辑 draft 明细、发放当月工资条并导出 CSV/XLSX;非 system.users 直接访问 /payroll 被拦截。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 管理员从 PeoplePage 进入 /payroll 成功;2) 在 /payroll 创建/编辑/停用薪资模板成功;3) 选择月份生成工资条后可看到员工列表与各项金额;4) 调整一名员工 draft 金额后 totals 刷新;5) 发放后该月工资条状态变为 issued;6) 导出指定月份 CSV/XLSX 成功;7) 非管理员访问 /payroll 进入 403。","manual","DONE","DONE","DONE","client/src/api/payroll.ts|client/src/api/exports.ts|client/src/pages/PayrollPage.tsx|client/src/pages/PeoplePage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/PayrollPage.ts","[""A2"",""A3"",""A4""]","/payroll 为独立路由,不加入全局主导航;入口放 PeoplePage 页头。QA 2026-03-29:client typecheck/build 通过;/payroll 路由守卫、PeoplePage 入口、模板管理、月度生成/发放/导出接线静态复核完成。"
"A6","R47 档案页薪资标签与员工自助查看","在 EmployeeProfilePage 增加薪资标签;管理员查看/编辑员工薪资快照并查看全部工资条,员工本人仅查看自己已发放工资条,其他角色不可见。","档案页支持 ?tab=salary 直达;system.users 可维护该员工薪资档案并查看全部工资条;员工本人只能查看自己的 issued 工资条列表与明细展开;report.dashboard.view 和其他非本人角色不显示薪资标签,也没有降级薪资视图。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 管理员打开任意员工档案可见薪资标签、薪资快照历史和全部工资条;2) 管理员保存新的 effectiveDate 薪资快照后列表刷新;3) 员工本人打开 /people/:me?tab=salary 只能看到已发放工资条;4) draft 工资条对员工本人不可见;5) report.dashboard.view 打开员工档案不显示薪资标签。","manual","DONE","DONE","DONE","client/src/pages/EmployeeProfilePage.tsx|client/src/api/payroll.ts","[""A2"",""A3"",""A5""]","薪资数据不并入现有 employee profile 响应,前端通过 payroll 专用接口加载,避免在现有档案权限模型下泄露。QA 2026-03-29:EmployeeProfilePage salary tab 与 EmployeeSalaryPanel 接入完成;本人仅看 issued、管理员可管理快照与全部工资条的权限分支静态复核通过。"
"A7","R47 统计页薪资卡片前端扩展","扩展员工统计前端类型与 PeopleStatsPage,在 system.users 场景展示当月/上月薪资总支出与人均薪资;其他角色维持无薪资卡片。","PeopleStatsPage 在 system.users 下显示 4 张薪资卡片(当月总支出、人均薪资、上月总支出、人均薪资);report.dashboard.view 仍可访问员工统计页,但不显示任何薪资数据;原有统计卡片和列表不回归。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) system.users 进入 /people/stats 可看到薪资汇总卡片;2) report.dashboard.view 进入同页看不到薪资卡片但原有人员统计仍正常;3) 无已发放工资条时显示薪资空态,不影响原有统计。","manual","DONE","DONE","DONE","client/src/api/employees.ts|client/src/pages/PeopleStatsPage.tsx","[""A4""]","薪资卡片仅依赖 salarySummary 是否为非空,不新增新的统计页或图表库。QA 2026-03-29:PeopleStatsPage salarySummary 条件渲染完成;client typecheck/build 通过,非 system.users 依赖 salarySummary=null 不渲染薪资卡片。"
"A8","Reviewer 与 QA 收口","按 AGENTS 编排启用 reviewer 与 qa,重点审查 Prisma 迁移、薪资高敏权限隔离、批量生成/发放事务安全、issued 冻结与导出审计,并回填 CSV 状态。","A2-A7 的 Review1_Status 与 Regression_Status 均有证据地回填;reviewer 风险项关闭;R43-R46 员工模块能力不回归。","Reviewer:审查 4 个新模型迁移回滚路径、system.users/self 权限边界、salarySummary 对 report.dashboard.view 的隔离、工资条生成/发放事务安全、issued 后不可修改、导出审计日志是否完整。QA:运行 backend post-check、frontend post-check,并手工回归 5 条主线:薪资模板与员工薪资快照、工资条生成/编辑/发放、员工本人查看工资条、指定月份导出、统计页薪资卡片权限;同时确认 R43 合同/培训证书、R44 人员筛选批量统计、R46 绩效模块与 EmployeeProfilePage 既有标签不回归。","manual","DONE","DONE","DONE","issues/2026-03-29_r47-payroll.csv","[""A2"",""A3"",""A4"",""A5"",""A6"",""A7""]","reviewer 必启用:本轮涉及 4 个新模型、高敏薪资数据、批量生成事务和导出链路。Review/QA 2026-03-29:Prisma validate、server node --check、vitest 5 个 payroll/stats 用例、client typecheck/build 全通过;已复核 payroll.js 事务生成/发放、self-only issued 可见、salarySummary 权限隔离与 exports 审计链路。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R47 执行合同与范围冻结 创建 R47 Issue CSV,冻结范围为薪资模板、员工薪资档案、工资条生成与发放、员工查看、薪资统计与导出、/payroll 管理页;锁定薪资路由独立于 employees.js,薪资统计仅对 system.users 返回。 issues/2026-03-29_r47-payroll.csv 列完整、依赖清晰、测试方法可复现;范围仅覆盖本轮薪资管理核心流程。 对照 AGENTS.md 与 issues/README.md 检查列完整、Dependencies 为 JSON 数组字符串、Test_Method 含命令与业务流程步骤;确认范围不包含外部薪酬系统、银行打款、审批流、个税规则引擎或消息通知。 manual DONE DONE DONE issues/2026-03-29_r47-payroll.csv [] QA 2026-03-29:Issue CSV 已创建并按批准方案冻结范围,依赖与测试口径已落地。
3 A2 R47 薪资模板与员工薪资档案后端 新增 SalaryItem、EmployeeSalary 模型与 payroll 路由基础能力;实现薪资模板管理与员工薪资快照档案 API,档案以 effectiveDate 为整套快照保存,历史保留不删除。 system.users 可创建/编辑/停用薪资模板,可为员工保存指定 effectiveDate 的整套薪资快照;同一 effectiveDate 的快照重复保存时在事务内整套替换;非 system.users 访问或修改薪资模板/薪资档案返回 403。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/payroll.js && node --check src/app.js && npm test -- tests/unit/routes/payroll.items.test.js tests/unit/routes/payroll.salary-profile.test.js。业务:1) 管理员创建 earning/deduction 两类薪资模板成功并可停用;2) 为员工保存一套含多条薪资项的 effectiveDate 快照成功;3) 同一 effectiveDate 再次保存会替换该日快照而不会删除历史日期快照;4) 按员工查询薪资快照历史返回正确;5) 普通员工和 report.dashboard.view 访问薪资模板/薪资档案均返回 403。 manual DONE DONE DONE server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/payroll.js|server/src/app.js|server/tests/unit/routes/payroll.items.test.js|server/tests/unit/routes/payroll.salary-profile.test.js ["A1"] 回滚:先清理 payslip_items / payslips / employee_salaries / salary_items 数据,再回滚 Prisma migration 与 payroll route 注册。本轮不自动 seed 默认薪资模板,列表中的基础工资/津贴/奖金/扣除由管理员通过 UI/API 建立。QA 2026-03-29:Prisma validate、node --check src/routes/payroll.js、vitest payroll.items/payroll.salary-profile 通过,system.users 独占模板与快照权限。
4 A3 R47 工资条生成、调整与发放后端 新增 Payslip、PayslipItem 模型与工资条 API;实现按月批量生成 draft、逐人调整 draft、整月发放 issued,以及员工/管理员工资条查询。 管理员可按 YYYY-MM 为 active 员工批量生成工资条;任一员工缺少目标月生效薪资快照或目标月已存在工资条时整批失败并回滚;draft 可编辑明细且服务端重算应发/应扣/实发;issued 后冻结且员工本人仅能看到 issued。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/payroll.js && npm test -- tests/unit/routes/payroll.payslips.test.js。业务:1) 管理员先创建薪资模板和员工薪资快照,再按指定月份批量生成工资条成功;2) draft 明细可修改绩效奖金/缺勤扣款,gross/deduction/net 由服务端重算;3) 目标月已有工资条时再次生成返回冲突;4) active 员工缺少生效薪资快照时整批生成失败且不落库;5) 发放后状态变为 issued,员工本人查询只能看到 issued,管理员可看到该员工全部月份工资条;6) issued 工资条再次修改返回 409。 manual DONE DONE DONE server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/payroll.js|server/tests/unit/routes/payroll.payslips.test.js ["A2"] 回滚:先清理 payslip_items / payslips 数据,再回滚 Prisma migration 与工资条相关代码。pay_month 统一存为目标月第一天;生成口径按目标月月末生效的最新薪资快照,不做月内比例拆分。QA 2026-03-29:vitest payroll.payslips.test.js 通过;已复核生成与发放均包裹事务、issued 后冻结、员工本人仅能看到 issued。
5 A4 R47 薪资导出与统计后端 在 exports.js 增加指定月份工资条导出,在 employeeStats 中增加 salarySummary;薪资统计仅对 system.users 返回,其他角色拿到 null。 system.users 可导出指定月份工资条 CSV/XLSX,导出写审计日志且列包含工号、姓名、部门、各薪资项、应发、应扣、实发;/api/employees/stats 对 system.users 返回 currentMonth/previousMonth 薪资汇总,对 report.dashboard.view 不返回薪资数据。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/payroll.js && node --check src/routes/exports.js && node --check src/routes/employees.js && node --check src/services/employeeStats.js && npm test -- tests/unit/routes/payroll.exports.test.js tests/unit/routes/employees.stats.test.js。业务:1) 在已有指定月份工资条后导出 CSV/XLSX 成功,列顺序正确且金额与工资条一致;2) 导出审计日志写入 action/月份/匹配数量;3) system.users 请求员工统计可看到当月/上月薪资总支出与人均薪资;4) report.dashboard.view 请求员工统计时 salarySummary 为 null,且无法通过任何 payroll/export 接口获取薪资数据。 manual DONE DONE DONE server/src/routes/exports.js|server/src/routes/employees.js|server/src/services/employeeStats.js|server/src/routes/payroll.js|server/tests/unit/routes/payroll.exports.test.js|server/tests/unit/routes/employees.stats.test.js ["A2","A3"] 回滚:删除 payslip export 路由与 salarySummary 字段,恢复 employeeStats 原结构。薪资统计仅统计 issued 工资条的 net_amount。QA 2026-03-29:node --check exports/employees/employeeStats 与 vitest payroll.exports/employees.stats 通过;salarySummary 仅对 system.users 返回,导出链路含审计日志。
6 A5 R47 薪资管理页与前端 API 新增 payroll 前端 API、/payroll 页面、PeoplePage 入口和导出接入;管理员可管理薪资模板、按月生成/调整/发放工资条并导出。 system.users 可从 PeoplePage 进入 /payroll;页面可维护薪资模板、选择月份生成工资条、编辑 draft 明细、发放当月工资条并导出 CSV/XLSX;非 system.users 直接访问 /payroll 被拦截。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 管理员从 PeoplePage 进入 /payroll 成功;2) 在 /payroll 创建/编辑/停用薪资模板成功;3) 选择月份生成工资条后可看到员工列表与各项金额;4) 调整一名员工 draft 金额后 totals 刷新;5) 发放后该月工资条状态变为 issued;6) 导出指定月份 CSV/XLSX 成功;7) 非管理员访问 /payroll 进入 403。 manual DONE DONE DONE client/src/api/payroll.ts|client/src/api/exports.ts|client/src/pages/PayrollPage.tsx|client/src/pages/PeoplePage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/PayrollPage.ts ["A2","A3","A4"] /payroll 为独立路由,不加入全局主导航;入口放 PeoplePage 页头。QA 2026-03-29:client typecheck/build 通过;/payroll 路由守卫、PeoplePage 入口、模板管理、月度生成/发放/导出接线静态复核完成。
7 A6 R47 档案页薪资标签与员工自助查看 在 EmployeeProfilePage 增加薪资标签;管理员查看/编辑员工薪资快照并查看全部工资条,员工本人仅查看自己已发放工资条,其他角色不可见。 档案页支持 ?tab=salary 直达;system.users 可维护该员工薪资档案并查看全部工资条;员工本人只能查看自己的 issued 工资条列表与明细展开;report.dashboard.view 和其他非本人角色不显示薪资标签,也没有降级薪资视图。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 管理员打开任意员工档案可见薪资标签、薪资快照历史和全部工资条;2) 管理员保存新的 effectiveDate 薪资快照后列表刷新;3) 员工本人打开 /people/:me?tab=salary 只能看到已发放工资条;4) draft 工资条对员工本人不可见;5) report.dashboard.view 打开员工档案不显示薪资标签。 manual DONE DONE DONE client/src/pages/EmployeeProfilePage.tsx|client/src/api/payroll.ts ["A2","A3","A5"] 薪资数据不并入现有 employee profile 响应,前端通过 payroll 专用接口加载,避免在现有档案权限模型下泄露。QA 2026-03-29:EmployeeProfilePage salary tab 与 EmployeeSalaryPanel 接入完成;本人仅看 issued、管理员可管理快照与全部工资条的权限分支静态复核通过。
8 A7 R47 统计页薪资卡片前端扩展 扩展员工统计前端类型与 PeopleStatsPage,在 system.users 场景展示当月/上月薪资总支出与人均薪资;其他角色维持无薪资卡片。 PeopleStatsPage 在 system.users 下显示 4 张薪资卡片(当月总支出、人均薪资、上月总支出、人均薪资);report.dashboard.view 仍可访问员工统计页,但不显示任何薪资数据;原有统计卡片和列表不回归。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) system.users 进入 /people/stats 可看到薪资汇总卡片;2) report.dashboard.view 进入同页看不到薪资卡片但原有人员统计仍正常;3) 无已发放工资条时显示薪资空态,不影响原有统计。 manual DONE DONE DONE client/src/api/employees.ts|client/src/pages/PeopleStatsPage.tsx ["A4"] 薪资卡片仅依赖 salarySummary 是否为非空,不新增新的统计页或图表库。QA 2026-03-29:PeopleStatsPage salarySummary 条件渲染完成;client typecheck/build 通过,非 system.users 依赖 salarySummary=null 不渲染薪资卡片。
9 A8 Reviewer 与 QA 收口 按 AGENTS 编排启用 reviewer 与 qa,重点审查 Prisma 迁移、薪资高敏权限隔离、批量生成/发放事务安全、issued 冻结与导出审计,并回填 CSV 状态。 A2-A7 的 Review1_Status 与 Regression_Status 均有证据地回填;reviewer 风险项关闭;R43-R46 员工模块能力不回归。 Reviewer:审查 4 个新模型迁移回滚路径、system.users/self 权限边界、salarySummary 对 report.dashboard.view 的隔离、工资条生成/发放事务安全、issued 后不可修改、导出审计日志是否完整。QA:运行 backend post-check、frontend post-check,并手工回归 5 条主线:薪资模板与员工薪资快照、工资条生成/编辑/发放、员工本人查看工资条、指定月份导出、统计页薪资卡片权限;同时确认 R43 合同/培训证书、R44 人员筛选批量统计、R46 绩效模块与 EmployeeProfilePage 既有标签不回归。 manual DONE DONE DONE issues/2026-03-29_r47-payroll.csv ["A2","A3","A4","A5","A6","A7"] reviewer 必启用:本轮涉及 4 个新模型、高敏薪资数据、批量生成事务和导出链路。Review/QA 2026-03-29:Prisma validate、server node --check、vitest 5 个 payroll/stats 用例、client typecheck/build 全通过;已复核 payroll.js 事务生成/发放、self-only issued 可见、salarySummary 权限隔离与 exports 审计链路。
@@ -0,0 +1,108 @@
-- CreateEnum
CREATE TYPE "SalaryItemType" AS ENUM ('earning', 'deduction');
-- CreateEnum
CREATE TYPE "PayslipStatus" AS ENUM ('draft', 'issued');
-- CreateTable
CREATE TABLE "salary_items" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"item_type" "SalaryItemType" NOT NULL,
"sort_order" INTEGER NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "salary_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_salaries" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"salary_item_id" UUID NOT NULL,
"monthly_amount" DECIMAL(12,2) NOT NULL,
"effective_date" DATE NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "employee_salaries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payslips" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"pay_month" DATE NOT NULL,
"status" "PayslipStatus" NOT NULL DEFAULT 'draft',
"gross_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"deduction_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"net_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"issued_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "payslips_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payslip_items" (
"id" UUID NOT NULL,
"payslip_id" UUID NOT NULL,
"salary_item_id" UUID NOT NULL,
"amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
CONSTRAINT "payslip_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "salary_items_name_key" ON "salary_items"("name");
-- CreateIndex
CREATE INDEX "idx_salary_items_enabled_sort" ON "salary_items"("enabled", "sort_order");
-- CreateIndex
CREATE UNIQUE INDEX "employee_salaries_employee_item_effective_ux" ON "employee_salaries"("employee_id", "salary_item_id", "effective_date");
-- CreateIndex
CREATE INDEX "idx_employee_salaries_employee_effective" ON "employee_salaries"("employee_id", "effective_date");
-- CreateIndex
CREATE INDEX "idx_employee_salaries_salary_item" ON "employee_salaries"("salary_item_id");
-- CreateIndex
CREATE UNIQUE INDEX "payslips_employee_month_ux" ON "payslips"("employee_id", "pay_month");
-- CreateIndex
CREATE INDEX "idx_payslips_month_status" ON "payslips"("pay_month", "status");
-- CreateIndex
CREATE INDEX "idx_payslips_employee_month" ON "payslips"("employee_id", "pay_month");
-- CreateIndex
CREATE UNIQUE INDEX "payslip_items_payslip_salary_item_ux" ON "payslip_items"("payslip_id", "salary_item_id");
-- CreateIndex
CREATE INDEX "idx_payslip_items_salary_item" ON "payslip_items"("salary_item_id");
-- AddForeignKey
ALTER TABLE "employee_salaries" ADD CONSTRAINT "employee_salaries_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_salaries" ADD CONSTRAINT "employee_salaries_salary_item_id_fkey" FOREIGN KEY ("salary_item_id") REFERENCES "salary_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payslips" ADD CONSTRAINT "payslips_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payslip_items" ADD CONSTRAINT "payslip_items_payslip_id_fkey" FOREIGN KEY ("payslip_id") REFERENCES "payslips"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payslip_items" ADD CONSTRAINT "payslip_items_salary_item_id_fkey" FOREIGN KEY ("salary_item_id") REFERENCES "salary_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+90
查看文件
@@ -363,6 +363,16 @@ enum EmployeeTrainingStatus {
cancelled
}
enum SalaryItemType {
earning
deduction
}
enum PayslipStatus {
draft
issued
}
enum PerformanceReviewCycleType {
monthly
quarterly
@@ -630,6 +640,8 @@ model Employee {
contracts EmployeeContract[]
trainings EmployeeTraining[]
certificates EmployeeCertificate[]
salary_profiles EmployeeSalary[]
payslips Payslip[]
performance_reviews_received PerformanceReview[] @relation("PerformanceReviewEmployee")
performance_reviews_authored PerformanceReview[] @relation("PerformanceReviewReviewer")
@@ -2089,6 +2101,84 @@ model EmployeeCertificate {
@@map("employee_certificates")
}
model SalaryItem {
id String @id @default(uuid()) @db.Uuid
name String @unique
item_type SalaryItemType
sort_order Int
enabled Boolean @default(true)
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee_salaries EmployeeSalary[]
payslip_items PayslipItem[]
@@index([enabled, sort_order], map: "idx_salary_items_enabled_sort")
@@map("salary_items")
}
model EmployeeSalary {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
salary_item_id String @db.Uuid
monthly_amount Decimal @db.Decimal(12, 2)
effective_date DateTime @db.Date
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
salary_item SalaryItem @relation(fields: [salary_item_id], references: [id], onDelete: Restrict)
@@unique([employee_id, salary_item_id, effective_date], map: "employee_salaries_employee_item_effective_ux")
@@index([employee_id, effective_date], map: "idx_employee_salaries_employee_effective")
@@index([salary_item_id], map: "idx_employee_salaries_salary_item")
@@map("employee_salaries")
}
model Payslip {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
pay_month DateTime @db.Date
status PayslipStatus @default(draft)
gross_amount Decimal @default(0) @db.Decimal(12, 2)
deduction_amount Decimal @default(0) @db.Decimal(12, 2)
net_amount Decimal @default(0) @db.Decimal(12, 2)
issued_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
items PayslipItem[]
@@unique([employee_id, pay_month], map: "payslips_employee_month_ux")
@@index([pay_month, status], map: "idx_payslips_month_status")
@@index([employee_id, pay_month], map: "idx_payslips_employee_month")
@@map("payslips")
}
model PayslipItem {
id String @id @default(uuid()) @db.Uuid
payslip_id String @db.Uuid
salary_item_id String @db.Uuid
amount Decimal @default(0) @db.Decimal(12, 2)
payslip Payslip @relation(fields: [payslip_id], references: [id], onDelete: Cascade)
salary_item SalaryItem @relation(fields: [salary_item_id], references: [id], onDelete: Restrict)
@@unique([payslip_id, salary_item_id], map: "payslip_items_payslip_salary_item_ux")
@@index([salary_item_id], map: "idx_payslip_items_salary_item")
@@map("payslip_items")
}
model PerformanceReviewCycle {
id String @id @default(uuid()) @db.Uuid
name String
+2
查看文件
@@ -32,6 +32,7 @@ const masterDataHolidayCalendarsRoutes = require("./routes/masterDataHolidayCale
const masterDataSuppliersRoutes = require("./routes/masterDataSuppliers");
const employeesRoutes = require("./routes/employees");
const performanceRoutes = require("./routes/performance");
const payrollRoutes = require("./routes/payroll");
const expenseTypesRoutes = require("./routes/expenseTypes");
const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
@@ -315,6 +316,7 @@ async function buildApp() {
await app.register(usersRoutes, { prefix: "/api" });
await app.register(employeesRoutes, { prefix: "/api" });
await app.register(performanceRoutes, { prefix: "/api" });
await app.register(payrollRoutes, { prefix: "/api" });
await app.register(departmentsRoutes, { prefix: "/api" });
await app.register(masterDataRoutes, { prefix: "/api" });
await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" });
+1
查看文件
@@ -740,6 +740,7 @@ async function employeesRoutes(fastify) {
const stats = await buildEmployeeStats(fastify.prisma, {
timeZone: fastify.config.appTimezone,
includeSalarySummary: fastify.hasPermission(request.employee, "system.users"),
});
return sendData(reply, request, stats);
});
+106
查看文件
@@ -14,6 +14,7 @@ const {
buildTeamPresenceHistory,
resolvePresenceHistoryRange,
} = require("../services/presenceHistory");
const { loadMonthlyPayslips, parsePayMonth } = require("../services/payroll");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
@@ -74,6 +75,11 @@ const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
const PAYSLIP_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
month: z.string().regex(/^\d{4}-\d{2}$/),
});
const PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: DATE_ONLY_SCHEMA.optional(),
@@ -809,6 +815,61 @@ async function buildEmployeesXlsx(prisma, where, limit) {
return workbook.xlsx.writeBuffer();
}
function buildPayslipExportHeader(salaryItems) {
return [
"工号",
"姓名",
"部门",
...salaryItems.map((item) => item.name),
"应发合计",
"应扣合计",
"实发合计",
];
}
function buildPayslipExportRow(payslip, salaryItems) {
const amountByItemId = new Map((payslip.items ?? []).map((item) => [item.salaryItemId, item.amount]));
return [
payslip.employee?.employeeNo ?? "",
payslip.employee?.name ?? "",
payslip.employee?.department?.name ?? "",
...salaryItems.map((item) => String(amountByItemId.get(item.id) ?? 0)),
String(payslip.grossAmount ?? 0),
String(payslip.deductionAmount ?? 0),
String(payslip.netAmount ?? 0),
];
}
function buildPayslipsCsv(dataset) {
const rows = ["\ufeff", csvRow(buildPayslipExportHeader(dataset.salaryItems))];
for (const payslip of dataset.payslips) {
rows.push(csvRow(buildPayslipExportRow(payslip, dataset.salaryItems)));
}
return rows.join("");
}
async function buildPayslipsXlsx(dataset) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("payslips");
sheet.columns = buildPayslipExportHeader(dataset.salaryItems).map((header) => ({
header,
key: header,
width: header.length > 8 ? 18 : 14,
}));
for (const payslip of dataset.payslips) {
const row = buildPayslipExportRow(payslip, dataset.salaryItems);
const rowObject = {};
buildPayslipExportHeader(dataset.salaryItems).forEach((header, index) => {
rowObject[header] = row[index];
});
sheet.addRow(rowObject);
}
return workbook.xlsx.writeBuffer();
}
function presenceStatusLabel(status) {
if (status === "office") return "在岗";
if (status === "remote") return "居家";
@@ -938,6 +999,51 @@ async function exportsRoutes(fastify) {
},
);
fastify.get(
"/exports/payslips",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
const month = parsePayMonth(query.month);
if (!month) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { month: "invalid_month" },
});
}
const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_PAYSLIPS",
targetType: "export",
extra: {
format: resolveExportFormat(query),
month: query.month,
matchedTotal: dataset.payslips.length,
},
}),
});
const format = resolveExportFormat(query);
const filename = exportFilename(`payslips-${query.month}`, format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildPayslipsXlsx(dataset);
return reply.send(Buffer.from(buffer));
}
return reply.send(buildPayslipsCsv(dataset));
},
);
fastify.get(
"/exports/presence-history",
{
+817
查看文件
@@ -0,0 +1,817 @@
const { z } = require("zod");
const {
calculatePayslipTotals,
formatDateOnly,
loadMonthlyPayslips,
mapEmployeeSalarySnapshotRows,
mapPayslip,
mapSalaryItem,
normalizeMoneyInput,
parseDateOnly,
parsePayMonth,
sortSalaryItems,
} = require("../services/payroll");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const UUID_SCHEMA = z.string().uuid();
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/);
const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]);
const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]);
const SALARY_ITEM_CREATE_SCHEMA = z
.object({
name: z.string().trim().min(1).max(100),
itemType: SALARY_ITEM_TYPE_SCHEMA,
sortOrder: z.number().int().min(0).max(999),
enabled: z.boolean().optional(),
})
.strict();
const SALARY_ITEM_PATCH_SCHEMA = SALARY_ITEM_CREATE_SCHEMA.partial()
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const SALARY_PROFILE_UPSERT_SCHEMA = z
.object({
effectiveDate: DATE_SCHEMA,
items: z
.array(
z
.object({
salaryItemId: UUID_SCHEMA,
monthlyAmount: MONEY_INPUT_SCHEMA,
})
.strict(),
)
.min(1),
})
.strict()
.superRefine((value, ctx) => {
const seen = new Set();
value.items.forEach((item, index) => {
if (seen.has(item.salaryItemId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["items", index, "salaryItemId"],
message: "duplicate_salary_item",
});
return;
}
seen.add(item.salaryItemId);
});
});
const PAYSLIP_QUERY_SCHEMA = z
.object({
month: PAY_MONTH_SCHEMA,
})
.strict();
const PAYSLIP_GENERATE_SCHEMA = z
.object({
month: PAY_MONTH_SCHEMA,
})
.strict();
const PAYSLIP_ISSUE_SCHEMA = z
.object({
month: PAY_MONTH_SCHEMA,
})
.strict();
const PAYSLIP_PATCH_SCHEMA = z
.object({
items: z
.array(
z
.object({
salaryItemId: UUID_SCHEMA,
amount: MONEY_INPUT_SCHEMA,
})
.strict(),
)
.min(1),
})
.strict()
.superRefine((value, ctx) => {
const seen = new Set();
value.items.forEach((item, index) => {
if (seen.has(item.salaryItemId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["items", index, "salaryItemId"],
message: "duplicate_salary_item",
});
return;
}
seen.add(item.salaryItemId);
});
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseQuery(schema, query) {
const parsed = schema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseMoneyList(items, fieldName) {
return items.map((item, index) => {
const normalized = normalizeMoneyInput(item[fieldName]);
if (!normalized) {
throw buildValidationError({
items: {
[index]: {
[fieldName]: "invalid_money_amount",
},
},
});
}
return {
...item,
[fieldName]: normalized,
};
});
}
function parseMonthOrThrow(monthText) {
const month = parsePayMonth(monthText);
if (!month) {
throw buildValidationError({ month: "invalid_month" });
}
return month;
}
function isUniqueConstraintError(error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
}
async function ensureEmployeeExists(prisma, employeeId) {
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department: { select: { id: true, name: true } },
},
});
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return employee;
}
async function ensureSalaryItemExists(prisma, salaryItemId) {
const item = await prisma.salaryItem.findUnique({
where: { id: salaryItemId },
});
if (!item) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return item;
}
async function ensurePayslipExists(prisma, payslipId) {
const payslip = await prisma.payslip.findUnique({
where: { id: payslipId },
include: {
employee: {
select: {
id: true,
employee_no: true,
name: true,
title: true,
department: { select: { id: true, name: true } },
},
},
items: {
include: {
salary_item: true,
},
},
},
});
if (!payslip) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return payslip;
}
function canViewEmployeePayslips(fastify, request, employeeId) {
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
}
async function payrollRoutes(fastify) {
fastify.get(
"/payroll/items",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const items = await fastify.prisma.salaryItem.findMany({
orderBy: [{ sort_order: "asc" }, { name: "asc" }],
});
return sendData(reply, request, {
items: items.map(mapSalaryItem),
});
},
);
fastify.post(
"/payroll/items",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body);
try {
const item = await fastify.prisma.salaryItem.create({
data: {
name: body.name,
item_type: body.itemType,
sort_order: body.sortOrder,
enabled: body.enabled ?? true,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "CREATE_SALARY_ITEM",
targetType: "payroll",
targetId: item.id,
extra: {
name: item.name,
itemType: item.item_type,
sortOrder: item.sort_order,
enabled: item.enabled,
},
}),
});
return sendData(reply, request, mapSalaryItem(item));
} catch (error) {
if (isUniqueConstraintError(error)) {
throw buildValidationError({ name: "duplicate" });
}
throw error;
}
},
);
fastify.patch(
"/payroll/items/:itemId",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const itemId = parseId(request.params?.itemId);
const body = parseBody(SALARY_ITEM_PATCH_SCHEMA, request.body);
await ensureSalaryItemExists(fastify.prisma, itemId);
try {
const item = await fastify.prisma.salaryItem.update({
where: { id: itemId },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.itemType !== undefined ? { item_type: body.itemType } : {}),
...(body.sortOrder !== undefined ? { sort_order: body.sortOrder } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "UPDATE_SALARY_ITEM",
targetType: "payroll",
targetId: item.id,
extra: {
name: item.name,
itemType: item.item_type,
sortOrder: item.sort_order,
enabled: item.enabled,
},
}),
});
return sendData(reply, request, mapSalaryItem(item));
} catch (error) {
if (isUniqueConstraintError(error)) {
throw buildValidationError({ name: "duplicate" });
}
throw error;
}
},
);
fastify.get(
"/payroll/employees/:employeeId/profile",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const employeeId = parseId(request.params?.employeeId);
await ensureEmployeeExists(fastify.prisma, employeeId);
const rows = await fastify.prisma.employeeSalary.findMany({
where: { employee_id: employeeId },
include: {
salary_item: true,
},
orderBy: [
{ effective_date: "desc" },
{ salary_item: { sort_order: "asc" } },
{ salary_item: { name: "asc" } },
],
});
return sendData(reply, request, {
employeeId,
snapshots: mapEmployeeSalarySnapshotRows(rows),
});
},
);
fastify.put(
"/payroll/employees/:employeeId/profile",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const employeeId = parseId(request.params?.employeeId);
const body = parseBody(SALARY_PROFILE_UPSERT_SCHEMA, request.body);
const normalizedItems = parseMoneyList(body.items, "monthlyAmount");
await ensureEmployeeExists(fastify.prisma, employeeId);
const salaryItemIds = normalizedItems.map((item) => item.salaryItemId);
const salaryItems = await fastify.prisma.salaryItem.findMany({
where: {
id: { in: salaryItemIds },
},
});
if (salaryItems.length !== salaryItemIds.length) {
throw buildValidationError({ items: "salary_item_not_found" });
}
const effectiveDate = parseDateOnly(body.effectiveDate);
const rows = await fastify.prisma.$transaction(async (tx) => {
await tx.employeeSalary.deleteMany({
where: {
employee_id: employeeId,
effective_date: effectiveDate,
},
});
if (normalizedItems.length) {
await tx.employeeSalary.createMany({
data: normalizedItems.map((item) => ({
employee_id: employeeId,
salary_item_id: item.salaryItemId,
monthly_amount: item.monthlyAmount,
effective_date: effectiveDate,
created_by: request.employee.id,
updated_by: request.employee.id,
})),
});
}
return tx.employeeSalary.findMany({
where: { employee_id: employeeId },
include: {
salary_item: true,
},
orderBy: [
{ effective_date: "desc" },
{ salary_item: { sort_order: "asc" } },
{ salary_item: { name: "asc" } },
],
});
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "UPSERT_EMPLOYEE_SALARY_PROFILE",
targetType: "payroll",
targetId: employeeId,
extra: {
effectiveDate: body.effectiveDate,
itemCount: normalizedItems.length,
},
}),
});
return sendData(reply, request, {
employeeId,
snapshots: mapEmployeeSalarySnapshotRows(rows),
});
},
);
fastify.get(
"/payroll/employees/:employeeId/payslips",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const employeeId = parseId(request.params?.employeeId);
if (!canViewEmployeePayslips(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
await ensureEmployeeExists(fastify.prisma, employeeId);
const isAdmin = fastify.hasPermission(request.employee, "system.users");
const payslips = await fastify.prisma.payslip.findMany({
where: {
employee_id: employeeId,
...(isAdmin ? {} : { status: "issued" }),
},
orderBy: [{ pay_month: "desc" }, { created_at: "desc" }],
include: {
employee: {
select: {
id: true,
employee_no: true,
name: true,
title: true,
department: { select: { id: true, name: true } },
},
},
items: {
include: {
salary_item: true,
},
},
},
});
return sendData(reply, request, {
employeeId,
items: payslips.map(mapPayslip),
});
},
);
fastify.get(
"/payroll/payslips",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query);
const month = parseMonthOrThrow(query.month);
const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate);
return sendData(reply, request, {
month: month.value,
salaryItems: dataset.salaryItems,
items: dataset.payslips,
summary: {
total: dataset.payslips.length,
draft: dataset.payslips.filter((item) => item.status === "draft").length,
issued: dataset.payslips.filter((item) => item.status === "issued").length,
},
});
},
);
fastify.post(
"/payroll/payslips/generate",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body);
const month = parseMonthOrThrow(body.month);
try {
const summary = await fastify.prisma.$transaction(async (tx) => {
const existingCount = await tx.payslip.count({
where: { pay_month: month.startDate },
});
if (existingCount > 0) {
throw buildValidationError({ month: "payslips_month_exists" }, 409);
}
const employees = await tx.employee.findMany({
where: { status: "active" },
orderBy: [{ employee_no: "asc" }],
select: {
id: true,
employee_no: true,
name: true,
},
});
if (!employees.length) {
throw buildValidationError({ month: "no_active_employees" }, 409);
}
const salaryItems = sortSalaryItems(
await tx.salaryItem.findMany({
where: { enabled: true },
}),
);
if (!salaryItems.length) {
throw buildValidationError({ items: "salary_items_empty" }, 409);
}
const salaryRows = await tx.employeeSalary.findMany({
where: {
employee_id: { in: employees.map((item) => item.id) },
effective_date: { lte: month.endDate },
},
select: {
employee_id: true,
salary_item_id: true,
monthly_amount: true,
effective_date: true,
},
});
const latestDateByEmployee = new Map();
for (const row of salaryRows) {
const effectiveDate = formatDateOnly(row.effective_date);
if (!latestDateByEmployee.has(row.employee_id) || effectiveDate > latestDateByEmployee.get(row.employee_id)) {
latestDateByEmployee.set(row.employee_id, effectiveDate);
}
}
const missingEmployees = employees.filter((item) => !latestDateByEmployee.has(item.id));
if (missingEmployees.length) {
throw buildValidationError(
{
month: "salary_profile_missing",
employees: missingEmployees.map((item) => ({
id: item.id,
employeeNo: item.employee_no,
name: item.name,
})),
},
409,
);
}
const snapshotAmountByEmployee = new Map();
for (const row of salaryRows) {
const effectiveDate = formatDateOnly(row.effective_date);
if (effectiveDate !== latestDateByEmployee.get(row.employee_id)) {
continue;
}
if (!snapshotAmountByEmployee.has(row.employee_id)) {
snapshotAmountByEmployee.set(row.employee_id, new Map());
}
snapshotAmountByEmployee.get(row.employee_id).set(
row.salary_item_id,
normalizeMoneyInput(String(row.monthly_amount)) ?? "0.00",
);
}
for (const employee of employees) {
const itemEntries = salaryItems.map((item) => ({
salary_item_id: item.id,
amount: snapshotAmountByEmployee.get(employee.id)?.get(item.id) ?? "0.00",
itemType: item.item_type,
}));
const totals = calculatePayslipTotals(
itemEntries.map((item) => ({
amount: item.amount,
itemType: item.itemType,
})),
);
await tx.payslip.create({
data: {
employee_id: employee.id,
pay_month: month.startDate,
status: "draft",
gross_amount: totals.grossAmountText,
deduction_amount: totals.deductionAmountText,
net_amount: totals.netAmountText,
created_by: request.employee.id,
updated_by: request.employee.id,
items: {
create: itemEntries.map((item) => ({
salary_item_id: item.salary_item_id,
amount: item.amount,
})),
},
},
});
}
return {
total: employees.length,
generated: employees.length,
};
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "GENERATE_PAYSLIPS",
targetType: "payroll",
extra: {
month: month.value,
summary,
},
}),
});
return sendData(reply, request, {
month: month.value,
summary,
});
} catch (error) {
if (isUniqueConstraintError(error)) {
throw buildValidationError({ month: "payslips_month_exists" }, 409);
}
throw error;
}
},
);
fastify.patch(
"/payroll/payslips/:payslipId",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const payslipId = parseId(request.params?.payslipId);
const body = parseBody(PAYSLIP_PATCH_SCHEMA, request.body);
const normalizedItems = parseMoneyList(body.items, "amount");
const current = await ensurePayslipExists(fastify.prisma, payslipId);
if (current.status !== "draft") {
throw buildValidationError({ payslip: "payslip_not_editable" }, 409);
}
const salaryItemIds = normalizedItems.map((item) => item.salaryItemId);
const salaryItems = await fastify.prisma.salaryItem.findMany({
where: { id: { in: salaryItemIds } },
});
if (salaryItems.length !== salaryItemIds.length) {
throw buildValidationError({ items: "salary_item_not_found" });
}
const salaryItemMap = new Map(salaryItems.map((item) => [item.id, item]));
const totals = calculatePayslipTotals(
normalizedItems.map((item) => ({
amount: item.amount,
itemType: salaryItemMap.get(item.salaryItemId).item_type,
})),
);
await fastify.prisma.$transaction(async (tx) => {
await tx.payslipItem.deleteMany({
where: { payslip_id: payslipId },
});
await tx.payslipItem.createMany({
data: normalizedItems.map((item) => ({
payslip_id: payslipId,
salary_item_id: item.salaryItemId,
amount: item.amount,
})),
});
await tx.payslip.update({
where: { id: payslipId },
data: {
gross_amount: totals.grossAmountText,
deduction_amount: totals.deductionAmountText,
net_amount: totals.netAmountText,
updated_by: request.employee.id,
},
});
});
const updated = await ensurePayslipExists(fastify.prisma, payslipId);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "UPDATE_PAYSLIP",
targetType: "payroll",
targetId: payslipId,
extra: {
payMonth: formatDateOnly(updated.pay_month)?.slice(0, 7) ?? null,
itemCount: normalizedItems.length,
},
}),
});
return sendData(reply, request, mapPayslip(updated));
},
);
fastify.post(
"/payroll/payslips/issue",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body);
const month = parseMonthOrThrow(body.month);
const summary = await fastify.prisma.$transaction(async (tx) => {
const payslips = await tx.payslip.findMany({
where: { pay_month: month.startDate },
select: {
id: true,
status: true,
},
});
if (!payslips.length) {
throw buildValidationError({ month: "payslips_not_generated" }, 409);
}
const draftIds = payslips.filter((item) => item.status === "draft").map((item) => item.id);
if (!draftIds.length) {
throw buildValidationError({ month: "payslips_already_issued" }, 409);
}
await tx.payslip.updateMany({
where: {
id: { in: draftIds },
},
data: {
status: "issued",
issued_at: new Date(),
updated_by: request.employee.id,
},
});
return {
total: payslips.length,
issued: draftIds.length,
};
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ISSUE_PAYSLIPS",
targetType: "payroll",
extra: {
month: month.value,
summary,
},
}),
});
return sendData(reply, request, {
month: month.value,
summary,
});
},
);
}
module.exports = payrollRoutes;
+67 -1
查看文件
@@ -6,6 +6,10 @@ function addDays(date, days) {
return next;
}
function shiftMonth(date, months) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1));
}
function toNumber(value) {
return value == null ? null : Number(value);
}
@@ -24,13 +28,29 @@ function buildPerformanceDistribution(rows) {
async function buildEmployeeStats(prisma, options = {}) {
const timeZone = options.timeZone ?? "Asia/Shanghai";
const includeSalarySummary = options.includeSalarySummary ?? true;
const todayString = currentDateString(timeZone);
const today = parseDateOnlyString(todayString);
const last30DateFrom = addDays(today, -29);
const next30DateTo = addDays(today, 30);
const currentMonthStart = parseDateOnlyString(`${todayString.slice(0, 7)}-01`);
const previousMonthStart = shiftMonth(currentMonthStart, -1);
const activeEmployeeWhere = { status: "active" };
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts, validCertificateHolders, expiringCertificates, activeCycle, completedCycle] = await Promise.all([
const [
employmentStatusRows,
departmentRows,
departments,
recentHires,
recentDepartures,
expiringContracts,
validCertificateHolders,
expiringCertificates,
activeCycle,
completedCycle,
currentMonthSalaryAggregate,
previousMonthSalaryAggregate,
] = await Promise.all([
prisma.employee.groupBy({
by: ["employment_status"],
where: activeEmployeeWhere,
@@ -102,6 +122,28 @@ async function buildEmployeeStats(prisma, options = {}) {
where: { status: "completed" },
orderBy: [{ completed_at: "desc" }, { end_date: "desc" }],
}),
includeSalarySummary
? prisma.payslip.aggregate({
where: {
pay_month: currentMonthStart,
status: "issued",
},
_sum: { net_amount: true },
_avg: { net_amount: true },
_count: { _all: true },
})
: Promise.resolve(null),
includeSalarySummary
? prisma.payslip.aggregate({
where: {
pay_month: previousMonthStart,
status: "issued",
},
_sum: { net_amount: true },
_avg: { net_amount: true },
_count: { _all: true },
})
: Promise.resolve(null),
]);
const departmentMap = new Map(departments.map((item) => [item.id, item.name]));
@@ -142,6 +184,29 @@ async function buildEmployeeStats(prisma, options = {}) {
};
}
const salarySummary = includeSalarySummary
? {
currentMonth:
currentMonthSalaryAggregate?._count?._all > 0
? {
month: currentMonthStart.toISOString().slice(0, 7),
totalNetAmount: toNumber(currentMonthSalaryAggregate._sum.net_amount) ?? 0,
averageNetAmount: toNumber(currentMonthSalaryAggregate._avg.net_amount) ?? 0,
issuedCount: currentMonthSalaryAggregate._count._all,
}
: null,
previousMonth:
previousMonthSalaryAggregate?._count?._all > 0
? {
month: previousMonthStart.toISOString().slice(0, 7),
totalNetAmount: toNumber(previousMonthSalaryAggregate._sum.net_amount) ?? 0,
averageNetAmount: toNumber(previousMonthSalaryAggregate._avg.net_amount) ?? 0,
issuedCount: previousMonthSalaryAggregate._count._all,
}
: null,
}
: null;
return {
asOfDate: todayString,
employmentStatusSummary: {
@@ -177,6 +242,7 @@ async function buildEmployeeStats(prisma, options = {}) {
validHolderCount: validCertificateHolders.length,
expiringSoonCount: expiringCertificates,
},
salarySummary,
performanceSummary,
};
}
+276
查看文件
@@ -0,0 +1,276 @@
function parseDateOnly(value) {
return value ? new Date(`${value}T00:00:00.000Z`) : null;
}
function formatDateOnly(value) {
return value?.toISOString?.()?.slice(0, 10) ?? null;
}
function parsePayMonth(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}$/.test(value)) {
return null;
}
const [yearText, monthText] = value.split("-");
const year = Number(yearText);
const month = Number(monthText);
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
return null;
}
const startDate = new Date(Date.UTC(year, month - 1, 1));
const endDate = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999));
return {
value,
startDate,
endDate,
};
}
function formatPayMonth(value) {
return value?.toISOString?.()?.slice(0, 7) ?? null;
}
function shiftMonth(startDate, delta) {
return new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() + delta, 1));
}
function sortSalaryItems(items) {
return [...(items ?? [])].sort((left, right) => {
if ((left.sort_order ?? 0) !== (right.sort_order ?? 0)) {
return (left.sort_order ?? 0) - (right.sort_order ?? 0);
}
return String(left.name ?? "").localeCompare(String(right.name ?? ""));
});
}
function normalizeMoneyInput(value) {
if (typeof value === "number") {
if (!Number.isFinite(value) || value < 0) {
return null;
}
const rounded = Number(value.toFixed(2));
if (Math.abs(value - rounded) > 1e-9) {
return null;
}
return rounded.toFixed(2);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) {
return null;
}
return Number(trimmed).toFixed(2);
}
return null;
}
function moneyTextToCents(value) {
const normalized = normalizeMoneyInput(value);
if (!normalized) {
return null;
}
const [integerPart, decimalPart] = normalized.split(".");
return Number(integerPart) * 100 + Number(decimalPart);
}
function centsToMoneyText(value) {
return (value / 100).toFixed(2);
}
function centsToNumber(value) {
return Number(centsToMoneyText(value));
}
function toNumber(value) {
return value == null ? null : Number(value);
}
function mapSalaryItem(item) {
return {
id: item.id,
name: item.name,
itemType: item.item_type,
sortOrder: item.sort_order,
enabled: item.enabled,
createdAt: item.created_at?.toISOString?.() ?? null,
createdBy: item.created_by ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
updatedBy: item.updated_by ?? null,
};
}
function mapEmployeeSalarySnapshotRows(rows) {
const snapshots = new Map();
for (const row of rows) {
const key = formatDateOnly(row.effective_date);
if (!snapshots.has(key)) {
snapshots.set(key, {
effectiveDate: key,
createdAt: row.created_at?.toISOString?.() ?? null,
createdBy: row.created_by ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
updatedBy: row.updated_by ?? null,
items: [],
});
}
snapshots.get(key).items.push({
salaryItemId: row.salary_item.id,
name: row.salary_item.name,
itemType: row.salary_item.item_type,
sortOrder: row.salary_item.sort_order,
monthlyAmount: toNumber(row.monthly_amount) ?? 0,
});
}
return [...snapshots.values()]
.map((snapshot) => ({
...snapshot,
items: snapshot.items.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name)),
}))
.sort((left, right) => right.effectiveDate.localeCompare(left.effectiveDate));
}
function calculatePayslipTotals(items) {
let grossCents = 0;
let deductionCents = 0;
for (const item of items) {
const cents = moneyTextToCents(item.amount);
if (cents == null) {
throw new Error("invalid_money_value");
}
if (item.itemType === "deduction") {
deductionCents += cents;
} else {
grossCents += cents;
}
}
const netCents = grossCents - deductionCents;
return {
grossAmount: centsToNumber(grossCents),
deductionAmount: centsToNumber(deductionCents),
netAmount: centsToNumber(netCents),
grossAmountText: centsToMoneyText(grossCents),
deductionAmountText: centsToMoneyText(deductionCents),
netAmountText: centsToMoneyText(netCents),
};
}
function mapPayslipItem(item) {
return {
salaryItemId: item.salary_item.id,
name: item.salary_item.name,
itemType: item.salary_item.item_type,
sortOrder: item.salary_item.sort_order,
amount: toNumber(item.amount) ?? 0,
};
}
function mapPayslip(payslip) {
return {
id: payslip.id,
employeeId: payslip.employee_id,
payMonth: formatPayMonth(payslip.pay_month),
status: payslip.status,
grossAmount: toNumber(payslip.gross_amount) ?? 0,
deductionAmount: toNumber(payslip.deduction_amount) ?? 0,
netAmount: toNumber(payslip.net_amount) ?? 0,
issuedAt: payslip.issued_at?.toISOString?.() ?? null,
createdAt: payslip.created_at?.toISOString?.() ?? null,
createdBy: payslip.created_by ?? null,
updatedAt: payslip.updated_at?.toISOString?.() ?? null,
updatedBy: payslip.updated_by ?? null,
employee: payslip.employee
? {
id: payslip.employee.id,
employeeNo: payslip.employee.employee_no,
name: payslip.employee.name,
title: payslip.employee.title,
department: payslip.employee.department
? {
id: payslip.employee.department.id,
name: payslip.employee.department.name,
}
: null,
}
: null,
items: [...(payslip.items ?? [])]
.sort((left, right) => {
const leftOrder = left.salary_item?.sort_order ?? 0;
const rightOrder = right.salary_item?.sort_order ?? 0;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.salary_item?.name ?? "").localeCompare(String(right.salary_item?.name ?? ""));
})
.map(mapPayslipItem),
};
}
async function loadMonthlyPayslips(prisma, monthStart) {
const payslips = await prisma.payslip.findMany({
where: { pay_month: monthStart },
orderBy: [{ created_at: "asc" }, { id: "asc" }],
include: {
employee: {
select: {
id: true,
employee_no: true,
name: true,
title: true,
department: { select: { id: true, name: true } },
},
},
items: {
include: {
salary_item: true,
},
},
},
});
const salaryItemMap = new Map();
for (const payslip of payslips) {
for (const item of payslip.items ?? []) {
if (item.salary_item && !salaryItemMap.has(item.salary_item.id)) {
salaryItemMap.set(item.salary_item.id, item.salary_item);
}
}
}
return {
salaryItems: sortSalaryItems([...salaryItemMap.values()]).map(mapSalaryItem),
payslips: payslips
.map(mapPayslip)
.sort(
(left, right) =>
String(left.employee?.employeeNo ?? "").localeCompare(String(right.employee?.employeeNo ?? "")) ||
String(left.id).localeCompare(String(right.id)),
),
};
}
module.exports = {
calculatePayslipTotals,
centsToMoneyText,
formatDateOnly,
formatPayMonth,
loadMonthlyPayslips,
mapEmployeeSalarySnapshotRows,
mapPayslip,
mapSalaryItem,
moneyTextToCents,
normalizeMoneyInput,
parseDateOnly,
parsePayMonth,
shiftMonth,
sortSalaryItems,
toNumber,
};
@@ -53,6 +53,18 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
{ overall_score: 4, _count: { _all: 3 } },
{ overall_score: 4.5, _count: { _all: 2 } },
]);
const payslipAggregate = vi
.fn()
.mockResolvedValueOnce({
_sum: { net_amount: 18800 },
_avg: { net_amount: 9400 },
_count: { _all: 2 },
})
.mockResolvedValueOnce({
_sum: { net_amount: 17600 },
_avg: { net_amount: 8800 },
_count: { _all: 2 },
});
app.decorate("prisma", {
employee: {
@@ -80,6 +92,9 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
aggregate: stats?.performanceReviewAggregate ?? performanceReviewAggregate,
groupBy: stats?.performanceReviewGroupBy ?? performanceReviewGroupBy,
},
payslip: {
aggregate: stats?.payslipAggregate ?? payslipAggregate,
},
});
app.decorate("authenticate", async (request) => {
request.employee = {
@@ -123,11 +138,12 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
performanceCycleFindFirst,
performanceReviewAggregate,
performanceReviewGroupBy,
payslipAggregate,
};
}
describe("employees stats route", () => {
it("returns aggregated employee stats for read-only viewers", async () => {
it("returns aggregated employee stats for read-only viewers without salary data", async () => {
const {
app,
employeeGroupBy,
@@ -139,6 +155,7 @@ describe("employees stats route", () => {
performanceCycleFindFirst,
performanceReviewAggregate,
performanceReviewGroupBy,
payslipAggregate,
} = await buildApp({
permissionCodes: ["report.dashboard.view"],
});
@@ -187,6 +204,7 @@ describe("employees stats route", () => {
{ score: 5, count: 0 },
],
});
expect(payload.data.salarySummary).toBeNull();
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
expect(employeeCount).toHaveBeenCalledTimes(2);
expect(departmentFindMany).toHaveBeenCalledTimes(1);
@@ -196,6 +214,37 @@ describe("employees stats route", () => {
expect(performanceCycleFindFirst).toHaveBeenCalledTimes(2);
expect(performanceReviewAggregate).toHaveBeenCalledTimes(1);
expect(performanceReviewGroupBy).toHaveBeenCalledTimes(1);
expect(payslipAggregate).not.toHaveBeenCalled();
await app.close();
});
it("returns salary summary for system.users viewers", async () => {
const { app, payslipAggregate } = await buildApp({
permissionCodes: ["system.users"],
});
const response = await app.inject({
method: "GET",
url: "/api/employees/stats",
});
expect(response.statusCode).toBe(200);
expect(response.json().data.salarySummary).toEqual({
currentMonth: {
month: expect.any(String),
totalNetAmount: 18800,
averageNetAmount: 9400,
issuedCount: 2,
},
previousMonth: {
month: expect.any(String),
totalNetAmount: 17600,
averageNetAmount: 8800,
issuedCount: 2,
},
});
expect(payslipAggregate).toHaveBeenCalledTimes(2);
await app.close();
});
@@ -0,0 +1,168 @@
const ExcelJS = require("exceljs");
const Fastify = require("fastify");
const exportsRoutes = require("../../../src/routes/exports");
const { AppError } = require("../../../src/utils/AppError");
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildPayslipRow() {
return {
id: "payslip-1",
employee_id: "employee-1",
pay_month: new Date("2026-04-01T00:00:00.000Z"),
status: "issued",
gross_amount: "10000.00",
deduction_amount: "600.00",
net_amount: "9400.00",
issued_at: new Date("2026-04-30T09:00:00.000Z"),
created_at: new Date("2026-04-30T08:00:00.000Z"),
created_by: ADMIN_ID,
updated_at: new Date("2026-04-30T09:00:00.000Z"),
updated_by: ADMIN_ID,
};
}
async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
} = {}) {
const app = Fastify();
const salaryItems = [
{
id: "salary-item-1",
name: "基本工资",
item_type: "earning",
sort_order: 10,
enabled: true,
},
{
id: "salary-item-2",
name: "社保扣除",
item_type: "deduction",
sort_order: 20,
enabled: false,
},
];
const payslips = [buildPayslipRow()];
const payslipItems = [
{
id: "item-1",
payslip_id: "payslip-1",
salary_item_id: "salary-item-1",
amount: "10000.00",
salary_item: salaryItems[0],
},
{
id: "item-2",
payslip_id: "payslip-1",
salary_item_id: "salary-item-2",
amount: "600.00",
salary_item: salaryItems[1],
},
];
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const codes = actor.permissionCodes ?? [];
if (!codes.includes("*") && !codes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employee, permissionCode) => {
const codes = employee.permissionCodes ?? [];
return codes.includes("*") || codes.includes(permissionCode);
});
app.decorate("prisma", {
payslip: {
findMany: vi.fn(async () =>
payslips.map((item) => ({
...item,
employee: {
id: "employee-1",
employee_no: "E001",
name: "Alice",
title: "专员",
department: { id: "dept-1", name: "行政部" },
},
items: payslipItems.filter((row) => row.payslip_id === item.id),
})),
),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(exportsRoutes, { prefix: "/api" });
return { app };
}
describe("payroll export routes", () => {
it("exports monthly payslips in csv and xlsx formats", async () => {
const { app } = await buildApp();
const csvResponse = await app.inject({
method: "GET",
url: "/api/exports/payslips?month=2026-04&format=csv",
});
expect(csvResponse.statusCode).toBe(200);
expect(csvResponse.headers["content-type"]).toContain("text/csv");
const csvBody = csvResponse.body;
expect(csvBody).toContain("工号,姓名,部门,基本工资,社保扣除,应发合计,应扣合计,实发合计");
expect(csvBody).toContain("E001,Alice,行政部,10000,600,10000,600,9400");
const xlsxResponse = await app.inject({
method: "GET",
url: "/api/exports/payslips?month=2026-04&format=xlsx",
});
expect(xlsxResponse.statusCode).toBe(200);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(Buffer.from(xlsxResponse.rawPayload));
const sheet = workbook.getWorksheet("payslips");
expect(sheet.getRow(1).values.slice(1, 9)).toEqual([
"工号",
"姓名",
"部门",
"基本工资",
"社保扣除",
"应发合计",
"应扣合计",
"实发合计",
]);
await app.close();
});
it("rejects non-admin exports", async () => {
const { app } = await buildApp({
actor: {
id: "employee-1",
permissionCodes: ["employee"],
},
});
const response = await app.inject({
method: "GET",
url: "/api/exports/payslips?month=2026-04&format=csv",
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -0,0 +1,160 @@
const Fastify = require("fastify");
const payrollRoutes = require("../../../src/routes/payroll");
const { AppError } = require("../../../src/utils/AppError");
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildSalaryItem(overrides = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
name: "基本工资",
item_type: "earning",
sort_order: 10,
enabled: true,
created_at: new Date("2026-03-29T08:00:00.000Z"),
created_by: ADMIN_ID,
updated_at: new Date("2026-03-29T08:00:00.000Z"),
updated_by: ADMIN_ID,
...overrides,
};
}
async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
} = {}) {
const app = Fastify();
const state = {
salaryItems: [buildSalaryItem()],
};
const salaryItem = {
findMany: vi.fn(async () => [...state.salaryItems].sort((left, right) => left.sort_order - right.sort_order)),
findUnique: vi.fn(async ({ where }) => state.salaryItems.find((item) => item.id === where.id) ?? null),
create: vi.fn(async ({ data }) => {
const item = buildSalaryItem({
id: "22222222-2222-4222-8222-222222222222",
name: data.name,
item_type: data.item_type,
sort_order: data.sort_order,
enabled: data.enabled,
created_by: data.created_by,
updated_by: data.updated_by,
});
state.salaryItems.push(item);
return item;
}),
update: vi.fn(async ({ where, data }) => {
const index = state.salaryItems.findIndex((item) => item.id === where.id);
state.salaryItems[index] = {
...state.salaryItems[index],
...data,
};
return state.salaryItems[index];
}),
};
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const codes = actor.permissionCodes ?? [];
if (!codes.includes("*") && !codes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employee, permissionCode) => {
const codes = employee.permissionCodes ?? [];
return codes.includes("*") || codes.includes(permissionCode);
});
app.decorate("prisma", {
salaryItem,
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(payrollRoutes, { prefix: "/api" });
return { app, state, salaryItem };
}
describe("payroll salary item routes", () => {
it("allows admins to create, list and patch salary items", async () => {
const { app, state, salaryItem } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: "/api/payroll/items",
payload: {
name: "岗位津贴",
itemType: "earning",
sortOrder: 20,
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json().data).toMatchObject({
name: "岗位津贴",
itemType: "earning",
sortOrder: 20,
enabled: true,
});
const patchResponse = await app.inject({
method: "PATCH",
url: `/api/payroll/items/${state.salaryItems[1].id}`,
payload: {
itemType: "deduction",
enabled: false,
},
});
expect(patchResponse.statusCode).toBe(200);
expect(patchResponse.json().data).toMatchObject({
name: "岗位津贴",
itemType: "deduction",
enabled: false,
});
const listResponse = await app.inject({
method: "GET",
url: "/api/payroll/items",
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toHaveLength(2);
expect(salaryItem.create).toHaveBeenCalledTimes(1);
expect(salaryItem.update).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects non-admin users", async () => {
const { app } = await buildApp({
actor: {
id: "employee-1",
permissionCodes: ["employee"],
},
});
const response = await app.inject({
method: "GET",
url: "/api/payroll/items",
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -0,0 +1,395 @@
const Fastify = require("fastify");
const payrollRoutes = require("../../../src/routes/payroll");
const { AppError } = require("../../../src/utils/AppError");
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const EMPLOYEE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const OTHER_EMPLOYEE_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
title: "专员",
status: "active",
department: { id: "dept-1", name: "行政部" },
...overrides,
};
}
function buildSalaryItem(overrides = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
name: "基本工资",
item_type: "earning",
sort_order: 10,
enabled: true,
...overrides,
};
}
function buildSalaryRow(overrides = {}) {
return {
id: "salary-1",
employee_id: EMPLOYEE_ID,
salary_item_id: "11111111-1111-4111-8111-111111111111",
monthly_amount: "10000.00",
effective_date: new Date("2026-04-01T00:00:00.000Z"),
...overrides,
};
}
const PAYSLIP_IDS = [
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
"ffffffff-ffff-4fff-8fff-ffffffffffff",
];
async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
omitProfileFor = [],
} = {}) {
const app = Fastify();
const state = {
employees: [
buildEmployee(),
buildEmployee({
id: OTHER_EMPLOYEE_ID,
employee_no: "E002",
name: "Bob",
}),
],
salaryItems: [
buildSalaryItem(),
buildSalaryItem({
id: "22222222-2222-4222-8222-222222222222",
name: "社保扣除",
item_type: "deduction",
sort_order: 20,
}),
],
salaryRows: [
buildSalaryRow(),
buildSalaryRow({
id: "salary-2",
employee_id: EMPLOYEE_ID,
salary_item_id: "22222222-2222-4222-8222-222222222222",
monthly_amount: "600.00",
}),
buildSalaryRow({
id: "salary-3",
employee_id: OTHER_EMPLOYEE_ID,
monthly_amount: "9000.00",
}),
buildSalaryRow({
id: "salary-4",
employee_id: OTHER_EMPLOYEE_ID,
salary_item_id: "22222222-2222-4222-8222-222222222222",
monthly_amount: "500.00",
}),
].filter((item) => !omitProfileFor.includes(item.employee_id)),
payslips: [],
payslipItems: [],
};
function hydratePayslip(payslip) {
return {
...payslip,
employee: state.employees.find((item) => item.id === payslip.employee_id),
items: state.payslipItems
.filter((item) => item.payslip_id === payslip.id)
.map((item) => ({
...item,
salary_item: state.salaryItems.find((salaryItem) => salaryItem.id === item.salary_item_id),
})),
};
}
const payslip = {
count: vi.fn(async ({ where }) => state.payslips.filter((item) => item.pay_month.getTime() === where.pay_month.getTime()).length),
findMany: vi.fn(async ({ where, select, include }) => {
const filtered = state.payslips.filter((item) => {
if (where?.pay_month && item.pay_month.getTime() !== where.pay_month.getTime()) {
return false;
}
if (where?.employee_id && item.employee_id !== where.employee_id) {
return false;
}
if (where?.status && item.status !== where.status) {
return false;
}
if (where?.id?.in && !where.id.in.includes(item.id)) {
return false;
}
return true;
});
if (select) {
return filtered.map((item) => ({
id: item.id,
status: item.status,
}));
}
if (include) {
return filtered.map(hydratePayslip);
}
return filtered;
}),
findUnique: vi.fn(async ({ where }) => {
const payslipRow = state.payslips.find((item) => item.id === where.id);
return payslipRow ? hydratePayslip(payslipRow) : null;
}),
create: vi.fn(async ({ data }) => {
const row = {
id: PAYSLIP_IDS[state.payslips.length] ?? PAYSLIP_IDS[0],
employee_id: data.employee_id,
pay_month: data.pay_month,
status: data.status,
gross_amount: data.gross_amount,
deduction_amount: data.deduction_amount,
net_amount: data.net_amount,
issued_at: data.issued_at ?? null,
created_at: new Date("2026-04-30T08:00:00.000Z"),
created_by: data.created_by,
updated_at: new Date("2026-04-30T08:00:00.000Z"),
updated_by: data.updated_by,
};
state.payslips.push(row);
state.payslipItems.push(
...(data.items?.create ?? []).map((item, index) => ({
id: `payslip-item-${state.payslipItems.length + index + 1}`,
payslip_id: row.id,
salary_item_id: item.salary_item_id,
amount: item.amount,
})),
);
return row;
}),
update: vi.fn(async ({ where, data }) => {
const index = state.payslips.findIndex((item) => item.id === where.id);
state.payslips[index] = {
...state.payslips[index],
...data,
updated_at: new Date("2026-04-30T09:00:00.000Z"),
};
return state.payslips[index];
}),
updateMany: vi.fn(async ({ where, data }) => {
let count = 0;
state.payslips = state.payslips.map((item) => {
if (where.id?.in && where.id.in.includes(item.id)) {
count += 1;
return {
...item,
...data,
};
}
return item;
});
return { count };
}),
};
const payslipItem = {
deleteMany: vi.fn(async ({ where }) => {
state.payslipItems = state.payslipItems.filter((item) => item.payslip_id !== where.payslip_id);
return { count: 1 };
}),
createMany: vi.fn(async ({ data }) => {
state.payslipItems.push(
...data.map((item, index) => ({
id: `payslip-item-${state.payslipItems.length + index + 1}`,
payslip_id: item.payslip_id,
salary_item_id: item.salary_item_id,
amount: item.amount,
})),
);
return { count: data.length };
}),
};
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const codes = actor.permissionCodes ?? [];
if (!codes.includes("*") && !codes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employee, permissionCode) => {
const codes = employee.permissionCodes ?? [];
return codes.includes("*") || codes.includes(permissionCode);
});
app.decorate("prisma", {
employee: {
findMany: vi.fn(async ({ where }) => {
if (where?.status === "active") {
return state.employees.filter((item) => item.status === "active");
}
return [...state.employees];
}),
findUnique: vi.fn(async ({ where }) => state.employees.find((item) => item.id === where.id) ?? null),
},
salaryItem: {
findMany: vi.fn(async ({ where }) => {
if (where?.enabled === true) {
return state.salaryItems.filter((item) => item.enabled);
}
if (where?.id?.in) {
return state.salaryItems.filter((item) => where.id.in.includes(item.id));
}
return [...state.salaryItems];
}),
},
employeeSalary: {
findMany: vi.fn(async ({ where, select }) =>
state.salaryRows.filter((item) => {
if (where.employee_id?.in && !where.employee_id.in.includes(item.employee_id)) {
return false;
}
if (where.effective_date?.lte && item.effective_date > where.effective_date.lte) {
return false;
}
return true;
}).map((item) => (select ? { ...item } : item)),
),
},
payslip,
payslipItem,
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback) => callback(app.prisma)),
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(payrollRoutes, { prefix: "/api" });
return { app, state, payslip, payslipItem };
}
describe("payroll payslip routes", () => {
it("generates, edits and issues payslips and limits self view to issued records", async () => {
const { app, state, payslipItem } = await buildApp();
const generateResponse = await app.inject({
method: "POST",
url: "/api/payroll/payslips/generate",
payload: { month: "2026-04" },
});
expect(generateResponse.statusCode).toBe(200);
expect(generateResponse.json().data.summary).toEqual({
total: 2,
generated: 2,
});
expect(state.payslips).toHaveLength(2);
const patchResponse = await app.inject({
method: "PATCH",
url: `/api/payroll/payslips/${state.payslips[0].id}`,
payload: {
items: [
{
salaryItemId: state.salaryItems[0].id,
amount: 11000,
},
{
salaryItemId: state.salaryItems[1].id,
amount: 500,
},
],
},
});
expect(patchResponse.statusCode).toBe(200);
expect(patchResponse.json().data).toMatchObject({
grossAmount: 11000,
deductionAmount: 500,
netAmount: 10500,
status: "draft",
});
expect(payslipItem.createMany).toHaveBeenCalledTimes(1);
const issueResponse = await app.inject({
method: "POST",
url: "/api/payroll/payslips/issue",
payload: { month: "2026-04" },
});
expect(issueResponse.statusCode).toBe(200);
expect(issueResponse.json().data.summary).toEqual({
total: 2,
issued: 2,
});
const frozenResponse = await app.inject({
method: "PATCH",
url: `/api/payroll/payslips/${state.payslips[0].id}`,
payload: {
items: [
{
salaryItemId: state.salaryItems[0].id,
amount: 12000,
},
],
},
});
expect(frozenResponse.statusCode).toBe(409);
await app.close();
const selfApp = await buildApp({
actor: {
id: EMPLOYEE_ID,
permissionCodes: ["employee"],
},
});
selfApp.state.payslips = state.payslips;
selfApp.state.payslipItems = state.payslipItems;
const selfResponse = await selfApp.app.inject({
method: "GET",
url: `/api/payroll/employees/${EMPLOYEE_ID}/payslips`,
});
expect(selfResponse.statusCode).toBe(200);
expect(selfResponse.json().data.items).toHaveLength(1);
expect(selfResponse.json().data.items[0].status).toBe("issued");
await selfApp.app.close();
});
it("fails generation when an active employee has no effective salary profile", async () => {
const { app, state } = await buildApp({
omitProfileFor: [OTHER_EMPLOYEE_ID],
});
const response = await app.inject({
method: "POST",
url: "/api/payroll/payslips/generate",
payload: { month: "2026-04" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.month).toBe("salary_profile_missing");
expect(state.payslips).toHaveLength(0);
await app.close();
});
});
@@ -0,0 +1,236 @@
const Fastify = require("fastify");
const payrollRoutes = require("../../../src/routes/payroll");
const { AppError } = require("../../../src/utils/AppError");
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const EMPLOYEE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
title: "专员",
status: "active",
department: { id: "dept-1", name: "行政部" },
...overrides,
};
}
function buildSalaryItem(overrides = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
name: "基本工资",
item_type: "earning",
sort_order: 10,
enabled: true,
created_at: new Date("2026-03-29T08:00:00.000Z"),
created_by: ADMIN_ID,
updated_at: new Date("2026-03-29T08:00:00.000Z"),
updated_by: ADMIN_ID,
...overrides,
};
}
function buildSalaryRow(overrides = {}) {
return {
id: "salary-1",
employee_id: EMPLOYEE_ID,
salary_item_id: "11111111-1111-4111-8111-111111111111",
monthly_amount: "10000.00",
effective_date: new Date("2026-01-01T00:00:00.000Z"),
created_at: new Date("2026-01-01T00:00:00.000Z"),
created_by: ADMIN_ID,
updated_at: new Date("2026-01-01T00:00:00.000Z"),
updated_by: ADMIN_ID,
salary_item: buildSalaryItem(),
...overrides,
};
}
async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
} = {}) {
const app = Fastify();
const state = {
employee: buildEmployee(),
salaryItems: [
buildSalaryItem(),
buildSalaryItem({
id: "22222222-2222-4222-8222-222222222222",
name: "社保扣除",
item_type: "deduction",
sort_order: 20,
}),
],
salaryRows: [buildSalaryRow()],
};
function attachSalaryItem(row) {
return {
...row,
salary_item: state.salaryItems.find((item) => item.id === row.salary_item_id),
};
}
const employeeSalary = {
findMany: vi.fn(async ({ where }) =>
state.salaryRows
.filter((item) => item.employee_id === where.employee_id)
.map(attachSalaryItem),
),
deleteMany: vi.fn(async ({ where }) => {
const effectiveDate = where.effective_date.toISOString().slice(0, 10);
state.salaryRows = state.salaryRows.filter(
(item) => !(item.employee_id === where.employee_id && item.effective_date.toISOString().slice(0, 10) === effectiveDate),
);
return { count: 1 };
}),
createMany: vi.fn(async ({ data }) => {
state.salaryRows.push(
...data.map((item, index) => ({
id: `salary-${state.salaryRows.length + index + 1}`,
employee_id: item.employee_id,
salary_item_id: item.salary_item_id,
monthly_amount: item.monthly_amount,
effective_date: item.effective_date,
created_at: new Date("2026-03-29T08:00:00.000Z"),
created_by: item.created_by,
updated_at: new Date("2026-03-29T08:00:00.000Z"),
updated_by: item.updated_by,
})),
);
return { count: data.length };
}),
};
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const codes = actor.permissionCodes ?? [];
if (!codes.includes("*") && !codes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employee, permissionCode) => {
const codes = employee.permissionCodes ?? [];
return codes.includes("*") || codes.includes(permissionCode);
});
app.decorate("prisma", {
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === state.employee.id ? state.employee : null)),
},
salaryItem: {
findMany: vi.fn(async ({ where }) => state.salaryItems.filter((item) => where.id.in.includes(item.id))),
findUnique: vi.fn(async ({ where }) => state.salaryItems.find((item) => item.id === where.id) ?? null),
},
employeeSalary,
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback) => callback(app.prisma)),
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(payrollRoutes, { prefix: "/api" });
return { app, state, employeeSalary };
}
describe("payroll salary profile routes", () => {
it("replaces salary snapshots for the same effective date and preserves history", async () => {
const { app, state, employeeSalary } = await buildApp();
const putResponse = await app.inject({
method: "PUT",
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
payload: {
effectiveDate: "2026-04-01",
items: [
{
salaryItemId: state.salaryItems[0].id,
monthlyAmount: 12000,
},
{
salaryItemId: state.salaryItems[1].id,
monthlyAmount: 800,
},
],
},
});
expect(putResponse.statusCode).toBe(200);
expect(putResponse.json().data.snapshots[0]).toMatchObject({
effectiveDate: "2026-04-01",
items: [
{ salaryItemId: state.salaryItems[0].id, monthlyAmount: 12000 },
{ salaryItemId: state.salaryItems[1].id, monthlyAmount: 800 },
],
});
expect(putResponse.json().data.snapshots[1]).toMatchObject({
effectiveDate: "2026-01-01",
});
const replaceResponse = await app.inject({
method: "PUT",
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
payload: {
effectiveDate: "2026-04-01",
items: [
{
salaryItemId: state.salaryItems[0].id,
monthlyAmount: 12500,
},
],
},
});
expect(replaceResponse.statusCode).toBe(200);
expect(replaceResponse.json().data.snapshots[0]).toMatchObject({
effectiveDate: "2026-04-01",
items: [{ salaryItemId: state.salaryItems[0].id, monthlyAmount: 12500 }],
});
expect(replaceResponse.json().data.snapshots).toHaveLength(2);
const getResponse = await app.inject({
method: "GET",
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
});
expect(getResponse.statusCode).toBe(200);
expect(getResponse.json().data.snapshots).toHaveLength(2);
expect(employeeSalary.deleteMany).toHaveBeenCalledTimes(2);
expect(employeeSalary.createMany).toHaveBeenCalledTimes(2);
await app.close();
});
it("rejects non-admin users", async () => {
const { app } = await buildApp({
actor: {
id: EMPLOYEE_ID,
permissionCodes: ["employee"],
},
});
const response = await app.inject({
method: "GET",
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
});
expect(response.statusCode).toBe(403);
await app.close();
});
});