R45 A2-A7 员工附件与培训证书管理

这个提交包含在:
2026-03-29 13:31:53 +08:00
父节点 d11f5593d1
当前提交 48f395e18d
共修改 14 个文件,包含 2291 行新增和 22 行删除
+146 -1
查看文件
@@ -1,9 +1,12 @@
import { apiRequestData } from "./http";
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
export type EmployeeStatus = "active" | "archived";
export type EmployeeEmploymentStatus = "onboarding" | "probation" | "active" | "offboarding" | "inactive";
export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship";
export type EmployeeContractStatus = "active" | "expired" | "terminated";
export type EmployeeTrainingType = "internal" | "external" | "online";
export type EmployeeTrainingStatus = "planned" | "in_progress" | "completed" | "cancelled";
export type EmployeeCertificateStatus = "valid" | "expired";
export type EmployeeDepartmentSummary = {
id: string;
@@ -87,6 +90,7 @@ export type EmployeeContract = {
endDate: string | null;
terminatedDate: string | null;
attachmentPath: string | null;
attachmentUrl: string | null;
status: EmployeeContractStatus;
createdAt: string | null;
createdBy: string | null;
@@ -94,6 +98,37 @@ export type EmployeeContract = {
updatedBy: string | null;
};
export type EmployeeTraining = {
id: string;
employeeId: string;
name: string;
trainingType: EmployeeTrainingType;
startDate: string | null;
endDate: string | null;
status: EmployeeTrainingStatus;
notes: string | null;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
};
export type EmployeeCertificate = {
id: string;
employeeId: string;
name: string;
issuer: string;
obtainedDate: string | null;
expiryDate: string | null;
certificateNo: string | null;
attachmentPath: string | null;
status: EmployeeCertificateStatus;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
};
export type OrganizationTreeNode = {
id: string;
employeeNo: string;
@@ -209,6 +244,12 @@ export type EmployeeStatsResponse = {
dateTo: string;
count: number;
};
certificateSummary: {
dateFrom: string;
dateTo: string;
validHolderCount: number;
expiringSoonCount: number;
};
};
export type BatchTransferDepartmentPayload = {
@@ -255,6 +296,12 @@ export async function uploadEmployeeAvatar(employeeId: string, file: File) {
});
}
export async function deleteEmployeeAvatar(employeeId: string) {
return apiRequestData<{ employeeId: string; avatarUrl: string | null }>(`/api/employees/${employeeId}/avatar`, {
method: "DELETE",
});
}
export async function patchEmployeeProfile(
employeeId: string,
payload: Partial<{
@@ -373,6 +420,104 @@ export async function patchEmployeeContract(
});
}
export async function uploadEmployeeContractAttachment(employeeId: string, contractId: string, file: File) {
const formData = new FormData();
formData.append("file", file);
return apiRequestData<EmployeeContract>(`/api/employees/${employeeId}/contracts/${contractId}/attachment`, {
method: "PUT",
body: formData,
});
}
export async function downloadEmployeeContractAttachment(attachmentUrl: string): Promise<ApiBlobResponse> {
return apiRequestBlob(attachmentUrl);
}
export async function deleteEmployeeContract(employeeId: string, contractId: string) {
return apiRequestData<{ contractId: string; deleted: boolean }>(`/api/employees/${employeeId}/contracts/${contractId}`, {
method: "DELETE",
});
}
export async function fetchEmployeeTrainings(employeeId: string) {
return apiRequestData<{ items: EmployeeTraining[] }>(`/api/employees/${employeeId}/trainings`);
}
export async function createEmployeeTraining(
employeeId: string,
payload: {
name: string;
trainingType: EmployeeTrainingType;
startDate: string;
endDate?: string | null;
status: EmployeeTrainingStatus;
notes?: string | null;
},
) {
return apiRequestData<EmployeeTraining>(`/api/employees/${employeeId}/trainings`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchEmployeeTraining(
employeeId: string,
trainingId: string,
payload: Partial<{
name: string;
trainingType: EmployeeTrainingType;
startDate: string;
endDate: string | null;
status: EmployeeTrainingStatus;
notes: string | null;
}>,
) {
return apiRequestData<EmployeeTraining>(`/api/employees/${employeeId}/trainings/${trainingId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function fetchEmployeeCertificates(employeeId: string) {
return apiRequestData<{ items: EmployeeCertificate[] }>(`/api/employees/${employeeId}/certificates`);
}
export async function createEmployeeCertificate(
employeeId: string,
payload: {
name: string;
issuer: string;
obtainedDate: string;
expiryDate?: string | null;
certificateNo?: string | null;
attachmentPath?: string | null;
},
) {
return apiRequestData<EmployeeCertificate>(`/api/employees/${employeeId}/certificates`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchEmployeeCertificate(
employeeId: string,
certificateId: string,
payload: Partial<{
name: string;
issuer: string;
obtainedDate: string;
expiryDate: string | null;
certificateNo: string | null;
attachmentPath: string | null;
}>,
) {
return apiRequestData<EmployeeCertificate>(`/api/employees/${employeeId}/certificates/${certificateId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function completeEmployeeChecklistTask(employeeId: string, taskId: string) {
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/checklist-tasks/${taskId}/complete`, {
method: "POST",
@@ -14,8 +14,11 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
pending_ocr: { label: "识别中", className: "badge-warning" },
approved: { label: "已通过", className: "badge-success" },
completed: { label: "已完成", className: "badge-success" },
in_progress: { label: "进行中", className: "badge-warning" },
available: { label: "可用", className: "badge-success" },
active: { label: "生效中", className: "badge-success" },
valid: { label: "有效", className: "badge-success" },
planned: { label: "已计划", className: "badge-primary" },
in_stock: { label: "库存中", className: "badge-primary" },
in_use: { label: "使用中", className: "badge-success" },
under_repair: { label: "维修中", className: "badge-warning" },
+599 -13
查看文件
@@ -3,18 +3,32 @@ import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
completeEmployeeChecklistTask,
createEmployeeCertificate,
createEmployeeContract,
createEmployeeTraining,
deleteEmployeeAvatar,
deleteEmployeeContract,
downloadEmployeeContractAttachment,
fetchEmployeeCertificates,
fetchEmployeeContracts,
fetchEmployeeProfile,
fetchEmployeeTrainings,
patchEmployeeCertificate,
patchEmployeeContract,
patchEmployeeProfile,
patchEmployeeTraining,
regularizeEmployee,
skipEmployeeChecklistTask,
startEmployeeOffboarding,
transferEmployee,
uploadEmployeeContractAttachment,
uploadEmployeeAvatar,
type EmployeeCertificate,
type EmployeeContract,
type EmployeeContractType,
type EmployeeTraining,
type EmployeeTrainingStatus,
type EmployeeTrainingType,
} from "../api/employees";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
@@ -29,8 +43,9 @@ import { Textarea } from "../components/ui/textarea";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { downloadBlob } from "../lib/download";
type ProfileTab = "profile" | "contracts";
type ProfileTab = "profile" | "contracts" | "trainingCertificates";
type ContractFormState = {
contractNo: string;
@@ -38,6 +53,23 @@ type ContractFormState = {
startDate: string;
endDate: string;
terminatedDate: string;
};
type TrainingFormState = {
name: string;
trainingType: EmployeeTrainingType;
startDate: string;
endDate: string;
status: EmployeeTrainingStatus;
notes: string;
};
type CertificateFormState = {
name: string;
issuer: string;
obtainedDate: string;
expiryDate: string;
certificateNo: string;
attachmentPath: string;
};
@@ -62,6 +94,27 @@ function buildEmptyContractForm(): ContractFormState {
startDate: "",
endDate: "",
terminatedDate: "",
};
}
function buildEmptyTrainingForm(): TrainingFormState {
return {
name: "",
trainingType: "internal",
startDate: "",
endDate: "",
status: "planned",
notes: "",
};
}
function buildEmptyCertificateForm(): CertificateFormState {
return {
name: "",
issuer: "",
obtainedDate: "",
expiryDate: "",
certificateNo: "",
attachmentPath: "",
};
}
@@ -70,6 +123,23 @@ function contractTypeLabel(type: EmployeeContractType) {
return CONTRACT_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
}
const TRAINING_TYPE_OPTIONS: Array<{ value: EmployeeTrainingType; label: string }> = [
{ value: "internal", label: "内部培训" },
{ value: "external", label: "外部培训" },
{ value: "online", label: "在线课程" },
];
const TRAINING_STATUS_OPTIONS: Array<{ value: EmployeeTrainingStatus; label: string }> = [
{ value: "planned", label: "已计划" },
{ value: "in_progress", label: "进行中" },
{ value: "completed", label: "已完成" },
{ value: "cancelled", label: "已取消" },
];
function trainingTypeLabel(type: EmployeeTrainingType) {
return TRAINING_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
}
export function EmployeeProfilePage() {
const { employeeId = "" } = useParams();
const { permissions, me } = useAuth();
@@ -88,7 +158,14 @@ export function EmployeeProfilePage() {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarInputKey, setAvatarInputKey] = useState(0);
const [editingContractId, setEditingContractId] = useState<string | null>(null);
const [contractAttachmentFile, setContractAttachmentFile] = useState<File | null>(null);
const [contractAttachmentInputKey, setContractAttachmentInputKey] = useState(0);
const [downloadingContractId, setDownloadingContractId] = useState<string | null>(null);
const [contractForm, setContractForm] = useState<ContractFormState>(buildEmptyContractForm());
const [editingTrainingId, setEditingTrainingId] = useState<string | null>(null);
const [trainingForm, setTrainingForm] = useState<TrainingFormState>(buildEmptyTrainingForm());
const [editingCertificateId, setEditingCertificateId] = useState<string | null>(null);
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
const [form, setForm] = useState({
title: "",
hireDate: "",
@@ -105,6 +182,16 @@ export function EmployeeProfilePage() {
queryKey: ["employee-contracts", employeeId],
queryFn: () => fetchEmployeeContracts(employeeId),
});
const trainingsQuery = useQuery({
enabled: Boolean(employeeId) && canManageEmployeeProfile && activeTab === "trainingCertificates",
queryKey: ["employee-trainings", employeeId],
queryFn: () => fetchEmployeeTrainings(employeeId),
});
const certificatesQuery = useQuery({
enabled: Boolean(employeeId) && canManageEmployeeProfile && activeTab === "trainingCertificates",
queryKey: ["employee-certificates", employeeId],
queryFn: () => fetchEmployeeCertificates(employeeId),
});
useEffect(() => {
if (!profileQuery.data) {
@@ -123,7 +210,7 @@ export function EmployeeProfilePage() {
}, [profileQuery.data]);
useEffect(() => {
if (canManageEmployeeProfile || activeTab !== "contracts") {
if (canManageEmployeeProfile || activeTab === "profile") {
return;
}
setActiveTab("profile");
@@ -134,6 +221,12 @@ export function EmployeeProfilePage() {
setAvatarInputKey((current) => current + 1);
setEditingContractId(null);
setContractForm(buildEmptyContractForm());
setContractAttachmentFile(null);
setContractAttachmentInputKey((current) => current + 1);
setEditingTrainingId(null);
setTrainingForm(buildEmptyTrainingForm());
setEditingCertificateId(null);
setCertificateForm(buildEmptyCertificateForm());
}, [employeeId]);
const saveMutation = useMutation({
@@ -184,6 +277,22 @@ export function EmployeeProfilePage() {
onError: (error) => toast.error(errorMessage(error, "头像上传失败")),
});
const deleteAvatarMutation = useMutation({
mutationFn: () => deleteEmployeeAvatar(employeeId),
onSuccess: async () => {
setAvatarFile(null);
setAvatarInputKey((current) => current + 1);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }),
queryClient.invalidateQueries({ queryKey: ["employees"] }),
queryClient.invalidateQueries({ queryKey: ["presence"] }),
queryClient.invalidateQueries({ queryKey: ["employee-organization"] }),
]);
toast.success("头像已删除");
},
onError: (error) => toast.error(errorMessage(error, "删除头像失败")),
});
const taskMutation = useMutation({
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
action === "complete"
@@ -231,7 +340,6 @@ export function EmployeeProfilePage() {
startDate: contractForm.startDate,
endDate: contractForm.endDate,
terminatedDate: contractForm.terminatedDate || null,
attachmentPath: contractForm.attachmentPath.trim() ? contractForm.attachmentPath.trim() : null,
};
if (editingContractId) {
@@ -248,6 +356,95 @@ export function EmployeeProfilePage() {
onError: (error) => toast.error(errorMessage(error, editingContractId ? "更新合同失败" : "新增合同失败")),
});
const contractAttachmentMutation = useMutation({
mutationFn: async () => {
if (!editingContractId) {
throw new Error("请先选择一个已保存的合同");
}
if (!contractAttachmentFile) {
throw new Error("请先选择附件文件");
}
return uploadEmployeeContractAttachment(employeeId, editingContractId, contractAttachmentFile);
},
onSuccess: async () => {
setContractAttachmentFile(null);
setContractAttachmentInputKey((current) => current + 1);
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
toast.success("合同附件已更新");
},
onError: (error) => toast.error(errorMessage(error, "上传合同附件失败")),
});
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => deleteEmployeeContract(employeeId, contractId),
onSuccess: async (_result, contractId) => {
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
if (editingContractId === contractId) {
setEditingContractId(null);
setContractForm(buildEmptyContractForm());
setContractAttachmentFile(null);
setContractAttachmentInputKey((current) => current + 1);
}
toast.success("合同已删除");
},
onError: (error) => toast.error(errorMessage(error, "删除合同失败")),
});
const trainingMutation = useMutation({
mutationFn: async () => {
const payload = {
name: trainingForm.name.trim(),
trainingType: trainingForm.trainingType,
startDate: trainingForm.startDate,
endDate: trainingForm.endDate || null,
status: trainingForm.status,
notes: trainingForm.notes.trim() ? trainingForm.notes.trim() : null,
};
if (editingTrainingId) {
return patchEmployeeTraining(employeeId, editingTrainingId, payload);
}
return createEmployeeTraining(employeeId, payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["employee-trainings", employeeId] });
setEditingTrainingId(null);
setTrainingForm(buildEmptyTrainingForm());
toast.success(editingTrainingId ? "培训记录已更新" : "培训记录已新增");
},
onError: (error) => toast.error(errorMessage(error, editingTrainingId ? "更新培训记录失败" : "新增培训记录失败")),
});
const certificateMutation = useMutation({
mutationFn: async () => {
const payload = {
name: certificateForm.name.trim(),
issuer: certificateForm.issuer.trim(),
obtainedDate: certificateForm.obtainedDate,
expiryDate: certificateForm.expiryDate || null,
certificateNo: certificateForm.certificateNo.trim() ? certificateForm.certificateNo.trim() : null,
attachmentPath: certificateForm.attachmentPath.trim() ? certificateForm.attachmentPath.trim() : null,
};
if (editingCertificateId) {
return patchEmployeeCertificate(employeeId, editingCertificateId, payload);
}
return createEmployeeCertificate(employeeId, payload);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["employee-certificates", employeeId] }),
queryClient.invalidateQueries({ queryKey: ["employee-stats"] }),
]);
setEditingCertificateId(null);
setCertificateForm(buildEmptyCertificateForm());
toast.success(editingCertificateId ? "证书记录已更新" : "证书记录已新增");
},
onError: (error) => toast.error(errorMessage(error, editingCertificateId ? "更新证书记录失败" : "新增证书记录失败")),
});
if (profileQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
}
@@ -272,10 +469,14 @@ export function EmployeeProfilePage() {
];
const directReports = profile.organization.directReports;
const contracts = contractsQuery.data?.items ?? [];
const trainings = trainingsQuery.data?.items ?? [];
const certificates = certificatesQuery.data?.items ?? [];
function resetContractEditor() {
setEditingContractId(null);
setContractForm(buildEmptyContractForm());
setContractAttachmentFile(null);
setContractAttachmentInputKey((current) => current + 1);
}
function startEditingContract(contract: EmployeeContract) {
@@ -286,8 +487,77 @@ export function EmployeeProfilePage() {
startDate: contract.startDate ?? "",
endDate: contract.endDate ?? "",
terminatedDate: contract.terminatedDate ?? "",
attachmentPath: contract.attachmentPath ?? "",
});
setContractAttachmentFile(null);
setContractAttachmentInputKey((current) => current + 1);
}
function resetTrainingEditor() {
setEditingTrainingId(null);
setTrainingForm(buildEmptyTrainingForm());
}
function startEditingTraining(training: EmployeeTraining) {
setEditingTrainingId(training.id);
setTrainingForm({
name: training.name,
trainingType: training.trainingType,
startDate: training.startDate ?? "",
endDate: training.endDate ?? "",
status: training.status,
notes: training.notes ?? "",
});
}
function resetCertificateEditor() {
setEditingCertificateId(null);
setCertificateForm(buildEmptyCertificateForm());
}
function startEditingCertificate(certificate: EmployeeCertificate) {
setEditingCertificateId(certificate.id);
setCertificateForm({
name: certificate.name,
issuer: certificate.issuer,
obtainedDate: certificate.obtainedDate ?? "",
expiryDate: certificate.expiryDate ?? "",
certificateNo: certificate.certificateNo ?? "",
attachmentPath: certificate.attachmentPath ?? "",
});
}
async function handleDownloadContractAttachment(contract: EmployeeContract) {
if (!contract.attachmentUrl) {
return;
}
try {
setDownloadingContractId(contract.id);
const response = await downloadEmployeeContractAttachment(contract.attachmentUrl);
downloadBlob(response.blob, response.filename ?? `${contract.contractNo}.bin`);
} catch (error) {
toast.error(errorMessage(error, "下载合同附件失败"));
} finally {
setDownloadingContractId(null);
}
}
function handleDeleteAvatar() {
if (!window.confirm("确认删除当前头像?")) {
return;
}
deleteAvatarMutation.mutate();
}
function handleDeleteContract(contract: EmployeeContract) {
if (contract.status === "active") {
toast.error("生效中的合同需先终止后再删除");
return;
}
if (!window.confirm(`确认删除合同 ${contract.contractNo}?`)) {
return;
}
deleteContractMutation.mutate(contract.id);
}
return (
@@ -316,6 +586,7 @@ export function EmployeeProfilePage() {
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="profile">档案</TabsTrigger>
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
</TabsList>
<TabsContent value="profile" className="space-y-6">
@@ -349,6 +620,14 @@ export function EmployeeProfilePage() {
<Button type="button" variant="secondary" onClick={() => avatarMutation.mutate()} disabled={!avatarFile || avatarMutation.isPending}>
{avatarMutation.isPending ? "上传中..." : "上传头像"}
</Button>
<Button
type="button"
variant="secondary"
onClick={handleDeleteAvatar}
disabled={!profile.employee.avatarUrl || deleteAvatarMutation.isPending}
>
{deleteAvatarMutation.isPending ? "删除中..." : "删除头像"}
</Button>
<div className="text-xs text-zinc-500">支持 jpg/png/webp,最大 2MB,自动裁剪为 200x200</div>
</div>
</div>
@@ -615,7 +894,7 @@ export function EmployeeProfilePage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">合同列表</h3>
<div className="mt-1 text-sm text-zinc-500">状态由终止日期与结束日期自动推导,本轮仅支持新增与编辑。</div>
<div className="mt-1 text-sm text-zinc-500">状态由终止日期与结束日期自动推导,附件支持上传/下载,active 合同需先终止后删除。</div>
</div>
<div className="text-sm text-zinc-500">{contracts.length} 份合同</div>
</div>
@@ -628,7 +907,7 @@ export function EmployeeProfilePage() {
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>日期</TableHead>
<TableHead>附件路径</TableHead>
<TableHead>附件</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
@@ -645,11 +924,39 @@ export function EmployeeProfilePage() {
<div>{contract.startDate ?? "-"} ~ {contract.endDate ?? "-"}</div>
<div className="text-xs text-zinc-500">终止:{contract.terminatedDate ?? "-"}</div>
</TableCell>
<TableCell className="max-w-[220px] truncate">{contract.attachmentPath ?? "-"}</TableCell>
<TableCell className="max-w-[220px]">
{contract.attachmentUrl ? (
<div className="space-y-2">
<div className="truncate text-xs text-zinc-500">{contract.attachmentPath ?? "-"}</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => handleDownloadContractAttachment(contract)}
disabled={downloadingContractId === contract.id}
>
{downloadingContractId === contract.id ? "下载中..." : "下载附件"}
</Button>
</div>
) : (
<div className="text-sm text-zinc-500">-</div>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingContract(contract)}>
编辑
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => handleDeleteContract(contract)}
disabled={deleteContractMutation.isPending || contract.status === "active"}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
))
@@ -668,7 +975,7 @@ export function EmployeeProfilePage() {
<div className="card-std space-y-4 p-6">
<div>
<h3 className="text-base font-semibold text-zinc-900">{editingContractId ? "编辑合同" : "新增合同"}</h3>
<div className="mt-1 text-sm text-zinc-500">附件本轮只保存路径字符串,不做上传。</div>
<div className="mt-1 text-sm text-zinc-500">新合同保存后再上传附件;附件仅支持 PDF/JPG/PNG,最大 10MB。</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
@@ -702,14 +1009,44 @@ export function EmployeeProfilePage() {
<label className="text-sm text-zinc-500">终止日期</label>
<Input type="date" value={contractForm.terminatedDate} onChange={(event) => setContractForm((prev) => ({ ...prev, terminatedDate: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">附件路径</label>
<Input value={contractForm.attachmentPath} onChange={(event) => setContractForm((prev) => ({ ...prev, attachmentPath: event.target.value }))} placeholder="例如:contracts/labor/E1001.pdf" />
</div>
</div>
{editingContractId ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
<div className="text-sm font-medium text-zinc-900">附件管理</div>
<div className="mt-1 text-xs text-zinc-500">
当前附件:{contracts.find((item) => item.id === editingContractId)?.attachmentPath ?? "未上传"}
</div>
<div className="mt-3 grid gap-3">
<Input
key={contractAttachmentInputKey}
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
onChange={(event) => setContractAttachmentFile(event.target.files?.[0] ?? null)}
/>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={() => contractMutation.mutate()} disabled={contractMutation.isPending}>
<Button
type="button"
variant="secondary"
onClick={() => contractAttachmentMutation.mutate()}
disabled={!contractAttachmentFile || contractAttachmentMutation.isPending}
>
{contractAttachmentMutation.isPending ? "上传中..." : "上传/替换附件"}
</Button>
<div className="text-xs text-zinc-500">同扩展名覆盖,变更扩展名时自动清理旧文件。</div>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-zinc-300 px-4 py-3 text-sm text-zinc-500">请先保存合同,再进行附件上传。</div>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => contractMutation.mutate()}
disabled={contractMutation.isPending || !contractForm.contractNo.trim() || !contractForm.startDate || !contractForm.endDate}
>
{contractMutation.isPending ? "保存中..." : editingContractId ? "保存修改" : "新增合同"}
</Button>
{editingContractId ? (
@@ -722,6 +1059,255 @@ export function EmployeeProfilePage() {
</div>
</TabsContent>
) : null}
{canManageEmployeeProfile ? (
<TabsContent value="trainingCertificates" className="space-y-6">
{trainingsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{errorMessage(trainingsQuery.error, "加载培训记录失败")}
</div>
) : null}
{certificatesQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{errorMessage(certificatesQuery.error, "加载证书记录失败")}
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
<div className="card-std p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">培训记录</h3>
<div className="mt-1 text-sm text-zinc-500">记录员工已计划、进行中和已完成培训。</div>
</div>
<div className="text-sm text-zinc-500">{trainings.length} 条记录</div>
</div>
<div className="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>日期</TableHead>
<TableHead>备注</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trainings.length ? (
trainings.map((training) => (
<TableRow key={training.id}>
<TableCell>{training.name}</TableCell>
<TableCell>{trainingTypeLabel(training.trainingType)}</TableCell>
<TableCell>
<StatusBadge status={training.status} />
</TableCell>
<TableCell>
<div>{training.startDate ?? "-"} ~ {training.endDate ?? "-"}</div>
</TableCell>
<TableCell className="max-w-[220px] truncate">{training.notes ?? "-"}</TableCell>
<TableCell>
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingTraining(training)}>
编辑
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={6}>
{trainingsQuery.isLoading ? "加载培训记录中..." : "暂无培训记录"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="card-std space-y-4 p-6">
<div>
<h3 className="text-base font-semibold text-zinc-900">{editingTrainingId ? "编辑培训记录" : "新增培训记录"}</h3>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
<div className="grid gap-2">
<label className="text-sm text-zinc-500">培训名称</label>
<Input value={trainingForm.name} onChange={(event) => setTrainingForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">培训类型</label>
<select
className="select-std"
value={trainingForm.trainingType}
onChange={(event) => setTrainingForm((prev) => ({ ...prev, trainingType: event.target.value as EmployeeTrainingType }))}
>
{TRAINING_TYPE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">开始日期</label>
<Input type="date" value={trainingForm.startDate} onChange={(event) => setTrainingForm((prev) => ({ ...prev, startDate: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">结束日期</label>
<Input type="date" value={trainingForm.endDate} onChange={(event) => setTrainingForm((prev) => ({ ...prev, endDate: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">状态</label>
<select
className="select-std"
value={trainingForm.status}
onChange={(event) => setTrainingForm((prev) => ({ ...prev, status: event.target.value as EmployeeTrainingStatus }))}
>
{TRAINING_STATUS_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">备注</label>
<Textarea value={trainingForm.notes} onChange={(event) => setTrainingForm((prev) => ({ ...prev, notes: event.target.value }))} />
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => trainingMutation.mutate()}
disabled={trainingMutation.isPending || !trainingForm.name.trim() || !trainingForm.startDate}
>
{trainingMutation.isPending ? "保存中..." : editingTrainingId ? "保存修改" : "新增培训"}
</Button>
{editingTrainingId ? (
<Button type="button" variant="secondary" onClick={resetTrainingEditor}>
取消编辑
</Button>
) : null}
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
<div className="card-std p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">证书记录</h3>
<div className="mt-1 text-sm text-zinc-500">本轮只保存证书附件路径字符串,不做文件上传。</div>
</div>
<div className="text-sm text-zinc-500">{certificates.length} 条记录</div>
</div>
<div className="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>证书</TableHead>
<TableHead>机构</TableHead>
<TableHead>状态</TableHead>
<TableHead>日期</TableHead>
<TableHead>编号 / 附件</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{certificates.length ? (
certificates.map((certificate) => (
<TableRow key={certificate.id}>
<TableCell>{certificate.name}</TableCell>
<TableCell>{certificate.issuer}</TableCell>
<TableCell>
<StatusBadge status={certificate.status} />
</TableCell>
<TableCell>
<div>取得:{certificate.obtainedDate ?? "-"}</div>
<div className="text-xs text-zinc-500">到期:{certificate.expiryDate ?? "-"}</div>
</TableCell>
<TableCell className="max-w-[220px]">
<div className="truncate">{certificate.certificateNo ?? "-"}</div>
<div className="truncate text-xs text-zinc-500">{certificate.attachmentPath ?? "-"}</div>
</TableCell>
<TableCell>
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingCertificate(certificate)}>
编辑
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={6}>
{certificatesQuery.isLoading ? "加载证书记录中..." : "暂无证书记录"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="card-std space-y-4 p-6">
<div>
<h3 className="text-base font-semibold text-zinc-900">{editingCertificateId ? "编辑证书记录" : "新增证书记录"}</h3>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
<div className="grid gap-2">
<label className="text-sm text-zinc-500">证书名称</label>
<Input value={certificateForm.name} onChange={(event) => setCertificateForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">颁发机构</label>
<Input value={certificateForm.issuer} onChange={(event) => setCertificateForm((prev) => ({ ...prev, issuer: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">获取日期</label>
<Input type="date" value={certificateForm.obtainedDate} onChange={(event) => setCertificateForm((prev) => ({ ...prev, obtainedDate: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">到期日期</label>
<Input type="date" value={certificateForm.expiryDate} onChange={(event) => setCertificateForm((prev) => ({ ...prev, expiryDate: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">证书编号</label>
<Input value={certificateForm.certificateNo} onChange={(event) => setCertificateForm((prev) => ({ ...prev, certificateNo: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">附件路径</label>
<Input
value={certificateForm.attachmentPath}
onChange={(event) => setCertificateForm((prev) => ({ ...prev, attachmentPath: event.target.value }))}
placeholder="例如:certificates/pmp.pdf"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => certificateMutation.mutate()}
disabled={certificateMutation.isPending || !certificateForm.name.trim() || !certificateForm.issuer.trim() || !certificateForm.obtainedDate}
>
{certificateMutation.isPending ? "保存中..." : editingCertificateId ? "保存修改" : "新增证书"}
</Button>
{editingCertificateId ? (
<Button type="button" variant="secondary" onClick={resetCertificateEditor}>
取消编辑
</Button>
) : null}
</div>
</div>
</div>
</TabsContent>
) : null}
</Tabs>
</div>
);
+13 -1
查看文件
@@ -44,7 +44,7 @@ export function PeopleStatsPage() {
}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<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">{stats.employmentStatusSummary.total}</div>
@@ -71,6 +71,18 @@ export function PeopleStatsPage() {
{stats.expiringContracts.dateFrom} 至 {stats.expiringContracts.dateTo}
</div>
</section>
<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">{stats.certificateSummary.validHolderCount}</div>
<div className="mt-2 text-xs text-zinc-500">按 active 员工去重统计有效证书持有人</div>
</section>
<section className="card-std p-5">
<div className="text-sm text-zinc-500">30 天内证书到期</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.certificateSummary.expiringSoonCount}</div>
<div className="mt-2 text-xs text-zinc-500">
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
</div>
</section>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
@@ -0,0 +1,8 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
A1,R45 合同建档与范围冻结,"创建 R45 Issue CSV,冻结范围为合同附件上传/下载、删除头像/合同、培训与证书记录、证书统计扩展;不夹带证书附件上传、培训/证书删除或权限模型重构。","issues/2026-03-29_r45-employee-attachments-training.csv 列完整、依赖清晰、测试方法可复现;范围仅覆盖本轮 3 项。","对照 AGENTS.md 与 issues/README.md 检查列完整、Dependencies 为 JSON 数组字符串、Test_Method 包含命令和业务流程步骤;确认范围不包含证书附件上传与培训/证书删除。",manual,DONE,DONE,DONE,issues/2026-03-29_r45-employee-attachments-training.csv,"[]",none
A2,合同附件后端与头像/合同删除,"在员工路由新增合同附件上传/下载、头像删除、合同删除;合同附件仅 system.users 可上传/下载,头像删除允许本人或管理员,文件删除使用 fs.unlink 且 ENOENT 忽略。","管理员可为既有合同上传和替换 PDF/JPG/PNG 附件,合同返回 attachmentUrl;管理员或本人可删除头像;管理员仅能删除 expired/terminated 合同,删除时会清理关联附件并记录审计日志。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.attachments.test.js。业务:1) system.users 为已有合同上传 PDF/JPG/PNG 附件成功,下载链接可访问,替换附件后仅最新文件可用;2) 非 system.users 访问合同附件上传/下载/删除返回 403;3) 本人和管理员删除头像成功,普通员工删除他人头像返回 403;4) active 合同删除被拒绝,expired/terminated 合同删除成功且附件文件不存在时也能幂等完成。",manual,DONE,DONE,DONE,server/src/routes/employees.js|server/tests/unit/routes/employees.attachments.test.js|server/tests/unit/routes/employees.profile.test.js,"[""A1""]","回滚:撤销合同附件与删除接口代码;必要时将残留 contract attachment_path/avatar_path 清空并删除 generated/storage/contracts、generated/storage/avatars 下本轮测试文件。删除逻辑按本地 storage driver 实现,非本地 driver 不在本轮兼容范围。"
A3,培训与证书后端模型与接口,"为 Employee 新增培训与证书模型、Prisma migration 以及管理员 CRUD 接口;证书附件本轮只维护路径字符串,不做上传。","EmployeeTraining 与 EmployeeCertificate 模型可迁移;GET/POST/PATCH /trainings 与 /certificates 均仅 system.users 可用;证书返回 valid/expired 推导状态;本轮不提供 DELETE。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.training.test.js && npm test -- tests/unit/routes/employees.certificates.test.js。业务:1) system.users 可新增、编辑、查询培训记录;2) system.users 可新增、编辑、查询证书记录,expiryDate 为空或未来日期时返回 valid,历史日期返回 expired;3) 非 system.users 访问 trainings/certificates 返回 403;4) 证书 attachmentPath 仅按字符串保存,不触发文件上传。",manual,DONE,DONE,DONE,server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/employees.js|server/tests/unit/routes/employees.training.test.js|server/tests/unit/routes/employees.certificates.test.js,"[""A2""]","回滚:先清理 employee_training / employee_certificate 数据,再回滚 Prisma migration 与代码;R45 Notes 需写明新增模型理由、回滚步骤,以及本轮不做培训/证书删除。"
A4,档案页前端接入合同附件、删除能力与培训证书页签,"更新前端 API 与 EmployeeProfilePage:支持删除头像、合同附件上传/下载/替换、删除合同,以及新增管理员专属的培训与证书页签。","档案页本人或管理员可删除头像;管理员可在合同列表下载附件、在编辑既有合同时上传/替换附件、删除 expired/terminated 合同;管理员可在培训与证书页签新增和编辑记录;普通员工看不到合同与培训证书管理入口。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 本人或管理员在档案页删除头像后恢复占位样式;2) 管理员先新增合同,再在编辑状态上传附件并可下载;3) active 合同删除按钮禁用或提示需先终止,expired/terminated 合同删除成功后列表刷新;4) 管理员可在“培训与证书”页签新增和编辑培训、证书;5) 普通员工或仅自助用户看不到合同与培训证书页签。",manual,DONE,DONE,DONE,client/src/pages/EmployeeProfilePage.tsx|client/src/api/employees.ts|client/src/components/common/StatusBadge.tsx,"[""A2"",""A3""]","约定:新合同必须先保存后上传附件;合同表单移除 attachmentPath 手工输入,证书表单保留 attachmentPath 字符串输入。"
A5,员工统计后端扩展证书指标,"扩展员工统计聚合,在现有 employmentStatusSummary、departmentHeadcount、recentMovements、expiringContracts 基础上新增 certificateSummary。","/api/employees/stats 返回 validHolderCount、expiringSoonCount、dateFrom/dateTo;统计仅基于 active 员工,且使用 Prisma count/groupBy 实现,不在应用层遍历全量记录。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/services/employeeStats.js && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.stats.test.js。业务:构造含有效证书、已过期证书、30 天内到期证书和 archived 员工证书的样本后,请求 /api/employees/stats,validHolderCount 只统计 active 员工且去重到员工维度,expiringSoonCount 统计未来 30 天到期的证书数。",manual,DONE,DONE,DONE,server/src/services/employeeStats.js|server/src/routes/employees.js|server/tests/unit/routes/employees.stats.test.js,"[""A3""]","回滚:删除 certificateSummary 聚合与返回字段,恢复统计接口原结构。"
A6,统计页前端补充证书卡片,"扩展 PeopleStatsPage 与前端员工 API 类型,展示有效证书人数与 30 天内到期证书数。","/people/stats 保持聚合视图不变,并新增 2 张证书统计卡片;原有 4 张卡片和列表展示不回归。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:进入 /people/stats 后可看到有效证书人数和 30 天内到期证书数;统计口径日期显示正确;原有在职人数、近 30 天入离职、合同到期、状态分布、部门人数分布继续正常。",manual,DONE,DONE,DONE,client/src/pages/PeopleStatsPage.tsx|client/src/api/employees.ts,"[""A4"",""A5""]","不引入图表库,继续使用数字卡片 + 简单列表。"
A7,Reviewer 与 QA 收口,"按 AGENTS 编排执行 reviewer 与 qa,重点检查 Prisma 迁移、文件删除安全、附件访问权限、统计口径与档案页回归,并回填 CSV 状态。","A2-A6 的 Review1_Status 与 Regression_Status 均有证据地回填;reviewer 风险项关闭;R43 员工头像/合同与 R44 员工统计能力不回归。","Reviewer:审查 migration 回滚路径、fs.unlink 删除边界、合同附件下载权限、active 合同删除防线、证书统计口径是否只影响聚合。QA:运行 backend post-check、frontend post-check,并手工回归 4 条主线:头像删除、合同附件上传/替换/下载/删除、培训与证书 CRUD、统计页证书卡片;同时确认 R43 头像上传/合同 CRUD 与 R44 统计原有四项不回归。",manual,DONE,DONE,DONE,issues/2026-03-29_r45-employee-attachments-training.csv,"[""A2"",""A3"",""A4"",""A5"",""A6""]","reviewer 必启用:本轮涉及 2 个 Prisma 新模型、文件删除操作、受限下载链路和删除安全性。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R45 合同建档与范围冻结 创建 R45 Issue CSV,冻结范围为合同附件上传/下载、删除头像/合同、培训与证书记录、证书统计扩展;不夹带证书附件上传、培训/证书删除或权限模型重构。 issues/2026-03-29_r45-employee-attachments-training.csv 列完整、依赖清晰、测试方法可复现;范围仅覆盖本轮 3 项。 对照 AGENTS.md 与 issues/README.md 检查列完整、Dependencies 为 JSON 数组字符串、Test_Method 包含命令和业务流程步骤;确认范围不包含证书附件上传与培训/证书删除。 manual DONE DONE DONE issues/2026-03-29_r45-employee-attachments-training.csv [] none
3 A2 合同附件后端与头像/合同删除 在员工路由新增合同附件上传/下载、头像删除、合同删除;合同附件仅 system.users 可上传/下载,头像删除允许本人或管理员,文件删除使用 fs.unlink 且 ENOENT 忽略。 管理员可为既有合同上传和替换 PDF/JPG/PNG 附件,合同返回 attachmentUrl;管理员或本人可删除头像;管理员仅能删除 expired/terminated 合同,删除时会清理关联附件并记录审计日志。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.attachments.test.js。业务:1) system.users 为已有合同上传 PDF/JPG/PNG 附件成功,下载链接可访问,替换附件后仅最新文件可用;2) 非 system.users 访问合同附件上传/下载/删除返回 403;3) 本人和管理员删除头像成功,普通员工删除他人头像返回 403;4) active 合同删除被拒绝,expired/terminated 合同删除成功且附件文件不存在时也能幂等完成。 manual DONE DONE DONE server/src/routes/employees.js|server/tests/unit/routes/employees.attachments.test.js|server/tests/unit/routes/employees.profile.test.js ["A1"] 回滚:撤销合同附件与删除接口代码;必要时将残留 contract attachment_path/avatar_path 清空并删除 generated/storage/contracts、generated/storage/avatars 下本轮测试文件。删除逻辑按本地 storage driver 实现,非本地 driver 不在本轮兼容范围。
4 A3 培训与证书后端模型与接口 为 Employee 新增培训与证书模型、Prisma migration 以及管理员 CRUD 接口;证书附件本轮只维护路径字符串,不做上传。 EmployeeTraining 与 EmployeeCertificate 模型可迁移;GET/POST/PATCH /trainings 与 /certificates 均仅 system.users 可用;证书返回 valid/expired 推导状态;本轮不提供 DELETE。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.training.test.js && npm test -- tests/unit/routes/employees.certificates.test.js。业务:1) system.users 可新增、编辑、查询培训记录;2) system.users 可新增、编辑、查询证书记录,expiryDate 为空或未来日期时返回 valid,历史日期返回 expired;3) 非 system.users 访问 trainings/certificates 返回 403;4) 证书 attachmentPath 仅按字符串保存,不触发文件上传。 manual DONE DONE DONE server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/employees.js|server/tests/unit/routes/employees.training.test.js|server/tests/unit/routes/employees.certificates.test.js ["A2"] 回滚:先清理 employee_training / employee_certificate 数据,再回滚 Prisma migration 与代码;R45 Notes 需写明新增模型理由、回滚步骤,以及本轮不做培训/证书删除。
5 A4 档案页前端接入合同附件、删除能力与培训证书页签 更新前端 API 与 EmployeeProfilePage:支持删除头像、合同附件上传/下载/替换、删除合同,以及新增管理员专属的培训与证书页签。 档案页本人或管理员可删除头像;管理员可在合同列表下载附件、在编辑既有合同时上传/替换附件、删除 expired/terminated 合同;管理员可在培训与证书页签新增和编辑记录;普通员工看不到合同与培训证书管理入口。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 本人或管理员在档案页删除头像后恢复占位样式;2) 管理员先新增合同,再在编辑状态上传附件并可下载;3) active 合同删除按钮禁用或提示需先终止,expired/terminated 合同删除成功后列表刷新;4) 管理员可在“培训与证书”页签新增和编辑培训、证书;5) 普通员工或仅自助用户看不到合同与培训证书页签。 manual DONE DONE DONE client/src/pages/EmployeeProfilePage.tsx|client/src/api/employees.ts|client/src/components/common/StatusBadge.tsx ["A2","A3"] 约定:新合同必须先保存后上传附件;合同表单移除 attachmentPath 手工输入,证书表单保留 attachmentPath 字符串输入。
6 A5 员工统计后端扩展证书指标 扩展员工统计聚合,在现有 employmentStatusSummary、departmentHeadcount、recentMovements、expiringContracts 基础上新增 certificateSummary。 /api/employees/stats 返回 validHolderCount、expiringSoonCount、dateFrom/dateTo;统计仅基于 active 员工,且使用 Prisma count/groupBy 实现,不在应用层遍历全量记录。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/services/employeeStats.js && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.stats.test.js。业务:构造含有效证书、已过期证书、30 天内到期证书和 archived 员工证书的样本后,请求 /api/employees/stats,validHolderCount 只统计 active 员工且去重到员工维度,expiringSoonCount 统计未来 30 天到期的证书数。 manual DONE DONE DONE server/src/services/employeeStats.js|server/src/routes/employees.js|server/tests/unit/routes/employees.stats.test.js ["A3"] 回滚:删除 certificateSummary 聚合与返回字段,恢复统计接口原结构。
7 A6 统计页前端补充证书卡片 扩展 PeopleStatsPage 与前端员工 API 类型,展示有效证书人数与 30 天内到期证书数。 /people/stats 保持聚合视图不变,并新增 2 张证书统计卡片;原有 4 张卡片和列表展示不回归。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:进入 /people/stats 后可看到有效证书人数和 30 天内到期证书数;统计口径日期显示正确;原有在职人数、近 30 天入离职、合同到期、状态分布、部门人数分布继续正常。 manual DONE DONE DONE client/src/pages/PeopleStatsPage.tsx|client/src/api/employees.ts ["A4","A5"] 不引入图表库,继续使用数字卡片 + 简单列表。
8 A7 Reviewer 与 QA 收口 按 AGENTS 编排执行 reviewer 与 qa,重点检查 Prisma 迁移、文件删除安全、附件访问权限、统计口径与档案页回归,并回填 CSV 状态。 A2-A6 的 Review1_Status 与 Regression_Status 均有证据地回填;reviewer 风险项关闭;R43 员工头像/合同与 R44 员工统计能力不回归。 Reviewer:审查 migration 回滚路径、fs.unlink 删除边界、合同附件下载权限、active 合同删除防线、证书统计口径是否只影响聚合。QA:运行 backend post-check、frontend post-check,并手工回归 4 条主线:头像删除、合同附件上传/替换/下载/删除、培训与证书 CRUD、统计页证书卡片;同时确认 R43 头像上传/合同 CRUD 与 R44 统计原有四项不回归。 manual DONE DONE DONE issues/2026-03-29_r45-employee-attachments-training.csv ["A2","A3","A4","A5","A6"] reviewer 必启用:本轮涉及 2 个 Prisma 新模型、文件删除操作、受限下载链路和删除安全性。
@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "EmployeeTrainingType" AS ENUM ('internal', 'external', 'online');
-- CreateEnum
CREATE TYPE "EmployeeTrainingStatus" AS ENUM ('planned', 'in_progress', 'completed', 'cancelled');
-- CreateTable
CREATE TABLE "employee_trainings" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"training_type" "EmployeeTrainingType" NOT NULL,
"start_date" DATE NOT NULL,
"end_date" DATE,
"status" "EmployeeTrainingStatus" NOT NULL,
"notes" TEXT,
"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_trainings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_certificates" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"obtained_date" DATE NOT NULL,
"expiry_date" DATE,
"certificate_no" TEXT,
"attachment_path" TEXT,
"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_certificates_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_employee_trainings_employee_start" ON "employee_trainings"("employee_id", "start_date");
-- CreateIndex
CREATE INDEX "idx_employee_trainings_employee_status" ON "employee_trainings"("employee_id", "status");
-- CreateIndex
CREATE INDEX "idx_employee_certificates_employee_obtained" ON "employee_certificates"("employee_id", "obtained_date");
-- CreateIndex
CREATE INDEX "idx_employee_certificates_employee_expiry" ON "employee_certificates"("employee_id", "expiry_date");
-- AddForeignKey
ALTER TABLE "employee_trainings" ADD CONSTRAINT "employee_trainings_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_certificates" ADD CONSTRAINT "employee_certificates_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+59
查看文件
@@ -350,6 +350,19 @@ enum EmployeeContractType {
internship
}
enum EmployeeTrainingType {
internal
external
online
}
enum EmployeeTrainingStatus {
planned
in_progress
completed
cancelled
}
enum WeeklyReportStatus {
draft
submitted
@@ -594,6 +607,8 @@ model Employee {
owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner")
owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner")
contracts EmployeeContract[]
trainings EmployeeTraining[]
certificates EmployeeCertificate[]
@@index([department_id])
@@index([legal_entity_id], map: "idx_employees_legal_entity")
@@ -2007,6 +2022,50 @@ model EmployeeContract {
@@map("employee_contracts")
}
model EmployeeTraining {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
name String
training_type EmployeeTrainingType
start_date DateTime @db.Date
end_date DateTime? @db.Date
status EmployeeTrainingStatus
notes String?
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)
@@index([employee_id, start_date], map: "idx_employee_trainings_employee_start")
@@index([employee_id, status], map: "idx_employee_trainings_employee_status")
@@map("employee_trainings")
}
model EmployeeCertificate {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
name String
issuer String
obtained_date DateTime @db.Date
expiry_date DateTime? @db.Date
certificate_no String?
attachment_path String?
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)
@@index([employee_id, obtained_date], map: "idx_employee_certificates_employee_obtained")
@@index([employee_id, expiry_date], map: "idx_employee_certificates_employee_expiry")
@@map("employee_certificates")
}
model ProjectDeliveryRecord {
id String @id @default(uuid()) @db.Uuid
project_id String @db.Uuid
+491 -1
查看文件
@@ -1,3 +1,6 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const sharp = require("sharp");
const { z } = require("zod");
@@ -10,14 +13,23 @@ const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { listOrganizationDepartments } = require("../services/organization");
const { StorageNotFoundError, openReadStream, writeBuffer } = require("../services/storage");
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
const UUID_SCHEMA = z.string().uuid();
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const REPORTING_CHAIN_MAX_DEPTH = 12;
const AVATAR_ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp"]);
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
const CONTRACT_ATTACHMENT_ALLOWED_TYPES = new Map([
["application/pdf", { extension: "pdf", contentType: "application/pdf" }],
["image/jpeg", { extension: "jpg", contentType: "image/jpeg" }],
["image/jpg", { extension: "jpg", contentType: "image/jpeg" }],
["image/png", { extension: "png", contentType: "image/png" }],
]);
const CONTRACT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
const CONTRACT_TYPE_SCHEMA = z.enum(["labor", "nda", "non_compete", "internship"]);
const TRAINING_TYPE_SCHEMA = z.enum(["internal", "external", "online"]);
const TRAINING_STATUS_SCHEMA = z.enum(["planned", "in_progress", "completed", "cancelled"]);
const PROFILE_PATCH_SCHEMA = z
.object({
departmentId: UUID_SCHEMA.optional(),
@@ -72,6 +84,32 @@ const CONTRACT_PATCH_SCHEMA = CONTRACT_CREATE_SCHEMA.partial()
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const TRAINING_CREATE_SCHEMA = z.object({
name: z.string().trim().min(1).max(200),
trainingType: TRAINING_TYPE_SCHEMA,
startDate: DATE_SCHEMA,
endDate: DATE_SCHEMA.nullable().optional(),
status: TRAINING_STATUS_SCHEMA,
notes: z.string().trim().max(1000).nullable().optional(),
}).strict();
const TRAINING_PATCH_SCHEMA = TRAINING_CREATE_SCHEMA.partial()
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const CERTIFICATE_CREATE_SCHEMA = z.object({
name: z.string().trim().min(1).max(200),
issuer: z.string().trim().min(1).max(200),
obtainedDate: DATE_SCHEMA,
expiryDate: DATE_SCHEMA.nullable().optional(),
certificateNo: z.string().trim().max(100).nullable().optional(),
attachmentPath: z.string().trim().max(500).nullable().optional(),
}).strict();
const CERTIFICATE_PATCH_SCHEMA = CERTIFICATE_CREATE_SCHEMA.partial()
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
@@ -125,6 +163,10 @@ function buildAvatarUrl(employeeId, avatarPath) {
return avatarPath ? `/api/avatars/${employeeId}` : null;
}
function buildContractAttachmentUrl(employeeId, contractId, attachmentPath) {
return attachmentPath ? `/api/employees/${employeeId}/contracts/${contractId}/attachment` : null;
}
function parseDateOnly(value) {
return value ? new Date(`${value}T00:00:00.000Z`) : null;
}
@@ -138,6 +180,18 @@ function validateContractDates(value) {
}
}
function validateTrainingDates(value) {
if (value.startDate && value.endDate && value.startDate > value.endDate) {
throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" });
}
}
function validateCertificateDates(value) {
if (value.obtainedDate && value.expiryDate && value.obtainedDate > value.expiryDate) {
throw buildValidationError({ expiryDate: "must_be_after_or_equal_obtainedDate" });
}
}
function deriveEmployeeContractStatus(contract, timeZone) {
if (contract.terminated_date) {
return "terminated";
@@ -162,6 +216,7 @@ function mapEmployeeContract(contract, timeZone) {
endDate: contract.end_date?.toISOString?.()?.slice(0, 10) ?? null,
terminatedDate: contract.terminated_date?.toISOString?.()?.slice(0, 10) ?? null,
attachmentPath: contract.attachment_path ?? null,
attachmentUrl: buildContractAttachmentUrl(contract.employee_id, contract.id, contract.attachment_path),
status: deriveEmployeeContractStatus(contract, timeZone),
createdAt: contract.created_at?.toISOString?.() ?? null,
createdBy: contract.created_by ?? null,
@@ -170,6 +225,106 @@ function mapEmployeeContract(contract, timeZone) {
};
}
function deriveEmployeeCertificateStatus(certificate, timeZone) {
const today = currentDateString(timeZone ?? "Asia/Shanghai");
const expiryDate = certificate.expiry_date?.toISOString?.()?.slice(0, 10) ?? null;
if (expiryDate && expiryDate < today) {
return "expired";
}
return "valid";
}
function mapEmployeeTraining(training) {
return {
id: training.id,
employeeId: training.employee_id,
name: training.name,
trainingType: training.training_type,
startDate: training.start_date?.toISOString?.()?.slice(0, 10) ?? null,
endDate: training.end_date?.toISOString?.()?.slice(0, 10) ?? null,
status: training.status,
notes: training.notes ?? null,
createdAt: training.created_at?.toISOString?.() ?? null,
createdBy: training.created_by ?? null,
updatedAt: training.updated_at?.toISOString?.() ?? null,
updatedBy: training.updated_by ?? null,
};
}
function mapEmployeeCertificate(certificate, timeZone) {
return {
id: certificate.id,
employeeId: certificate.employee_id,
name: certificate.name,
issuer: certificate.issuer,
obtainedDate: certificate.obtained_date?.toISOString?.()?.slice(0, 10) ?? null,
expiryDate: certificate.expiry_date?.toISOString?.()?.slice(0, 10) ?? null,
certificateNo: certificate.certificate_no ?? null,
attachmentPath: certificate.attachment_path ?? null,
status: deriveEmployeeCertificateStatus(certificate, timeZone),
createdAt: certificate.created_at?.toISOString?.() ?? null,
createdBy: certificate.created_by ?? null,
updatedAt: certificate.updated_at?.toISOString?.() ?? null,
updatedBy: certificate.updated_by ?? null,
};
}
function resolveContractAttachmentDescriptor(mimetype) {
return CONTRACT_ATTACHMENT_ALLOWED_TYPES.get(mimetype) ?? null;
}
function inferAttachmentContentType(storagePath) {
const extension = path.extname(parseStoragePath(storagePath).key ?? "").toLowerCase();
if (extension === ".pdf") {
return "application/pdf";
}
if (extension === ".jpg" || extension === ".jpeg") {
return "image/jpeg";
}
if (extension === ".png") {
return "image/png";
}
return "application/octet-stream";
}
function buildContractAttachmentFilename(contract) {
const key = parseStoragePath(contract.attachment_path).key ?? "";
const extension = path.extname(key) || ".bin";
const safeContractNo = String(contract.contract_no ?? "contract").replace(/[^a-zA-Z0-9._-]+/g, "_");
return `${safeContractNo}${extension}`;
}
function resolveLocalStorageAbsolutePath(config, storagePath) {
const parsed = parseStoragePath(storagePath);
if (!parsed.key || parsed.driver !== "local") {
return null;
}
const storageRoot = path.resolve(config.storageDir);
const absolutePath = path.resolve(storageRoot, parsed.key);
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
throw new Error("Invalid storage_path (path traversal detected)");
}
return absolutePath;
}
async function unlinkStoragePath(config, storagePath) {
const absolutePath = resolveLocalStorageAbsolutePath(config, storagePath);
if (!absolutePath) {
return;
}
try {
await fs.unlink(absolutePath);
} catch (error) {
if (error?.code === "ENOENT") {
return;
}
throw error;
}
}
function mapChecklistTask(item) {
return {
id: item.id,
@@ -240,6 +395,26 @@ async function ensureEmployeeContractExists(prisma, employeeId, contractId) {
return contract;
}
async function ensureEmployeeTrainingExists(prisma, employeeId, trainingId) {
const training = await prisma.employeeTraining.findFirst({
where: { id: trainingId, employee_id: employeeId },
});
if (!training) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return training;
}
async function ensureEmployeeCertificateExists(prisma, employeeId, certificateId) {
const certificate = await prisma.employeeCertificate.findFirst({
where: { id: certificateId, employee_id: employeeId },
});
if (!certificate) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return certificate;
}
async function maybeAdvanceLifecycle(tx, employeeId) {
const employee = await tx.employee.findUnique({ where: { id: employeeId } });
if (!employee) {
@@ -522,6 +697,32 @@ async function employeesRoutes(fastify) {
});
});
fastify.delete("/employees/:id/avatar", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canUploadAvatar(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.avatar.write" } });
}
const current = await ensureEmployeeExists(fastify.prisma, employeeId);
await fastify.prisma.employee.update({
where: { id: employeeId },
data: {
avatar_path: null,
updated_by: request.employee.id,
},
});
if (current.avatar_path) {
await unlinkStoragePath(fastify.config, current.avatar_path);
}
return sendData(reply, request, {
employeeId,
avatarUrl: null,
});
});
fastify.get("/employees/organization", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canViewPeopleDirectory(fastify, request)) {
@@ -572,6 +773,37 @@ async function employeesRoutes(fastify) {
});
});
fastify.get("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
const contract = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
if (!contract.attachment_path) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: contract.attachment_path,
});
reply.header("cache-control", "no-store");
reply.header("content-disposition", `attachment; filename="${buildContractAttachmentFilename(contract)}"`);
return reply.type(inferAttachmentContentType(contract.attachment_path)).send(opened.stream);
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
throw error;
}
});
fastify.post("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
@@ -651,6 +883,261 @@ async function employeesRoutes(fastify) {
}
});
fastify.put("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
if (!request.isMultipart()) {
throw buildValidationError({ file: "invalid_multipart" });
}
const filePart = await request.file();
if (!filePart) {
throw buildValidationError({ file: "required" });
}
if (filePart.fieldname !== "file") {
throw buildValidationError({ file: "invalid_field" });
}
const descriptor = resolveContractAttachmentDescriptor(filePart.mimetype);
if (!descriptor) {
throw buildValidationError({ file: "invalid_type" });
}
const rawBuffer = await filePart.toBuffer();
if (!rawBuffer.length) {
throw buildValidationError({ file: "empty" });
}
if (rawBuffer.length > CONTRACT_ATTACHMENT_MAX_BYTES) {
throw buildValidationError({ file: "file_too_large" });
}
const nextAttachmentPath = await writeBuffer({
config: fastify.config,
key: `contracts/${contractId}.${descriptor.extension}`,
buffer: rawBuffer,
contentType: descriptor.contentType,
});
const contract = await fastify.prisma.employeeContract.update({
where: { id: contractId },
data: {
attachment_path: nextAttachmentPath,
updated_by: request.employee.id,
},
});
if (current.attachment_path && current.attachment_path !== nextAttachmentPath) {
await unlinkStoragePath(fastify.config, current.attachment_path);
}
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
});
fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
const currentStatus = deriveEmployeeContractStatus(current, fastify.config.appTimezone);
if (currentStatus === "active") {
throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409);
}
await fastify.prisma.employeeContract.delete({ where: { id: contractId } });
if (current.attachment_path) {
await unlinkStoragePath(fastify.config, current.attachment_path);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EMPLOYEE_CONTRACT_DELETED",
targetType: "employee_contract",
targetId: contractId,
extra: {
employeeId,
contractNo: current.contract_no,
status: currentStatus,
attachmentPath: current.attachment_path ?? null,
},
}),
});
return sendData(reply, request, {
contractId,
deleted: true,
});
});
fastify.get("/employees/:id/trainings", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
await ensureEmployeeExists(fastify.prisma, employeeId);
const items = await fastify.prisma.employeeTraining.findMany({
where: { employee_id: employeeId },
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
});
return sendData(reply, request, { items: items.map(mapEmployeeTraining) });
});
fastify.post("/employees/:id/trainings", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(TRAINING_CREATE_SCHEMA, request.body);
validateTrainingDates(body);
await ensureEmployeeExists(fastify.prisma, employeeId);
const training = await fastify.prisma.employeeTraining.create({
data: {
employee_id: employeeId,
name: body.name,
training_type: body.trainingType,
start_date: parseDateOnly(body.startDate),
end_date: parseDateOnly(body.endDate ?? null),
status: body.status,
notes: body.notes ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
return sendData(reply, request, mapEmployeeTraining(training));
});
fastify.patch("/employees/:id/trainings/:trainingId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const trainingId = parseId(request.params?.trainingId);
const body = parseBody(TRAINING_PATCH_SCHEMA, request.body);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeTrainingExists(fastify.prisma, employeeId, trainingId);
validateTrainingDates({
startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10),
endDate: body.endDate !== undefined ? body.endDate : current.end_date?.toISOString?.()?.slice(0, 10),
});
const training = await fastify.prisma.employeeTraining.update({
where: { id: trainingId },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.trainingType !== undefined ? { training_type: body.trainingType } : {}),
...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}),
...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate ?? null) } : {}),
...(body.status !== undefined ? { status: body.status } : {}),
...(body.notes !== undefined ? { notes: body.notes ?? null } : {}),
updated_by: request.employee.id,
},
});
return sendData(reply, request, mapEmployeeTraining(training));
});
fastify.get("/employees/:id/certificates", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
await ensureEmployeeExists(fastify.prisma, employeeId);
const items = await fastify.prisma.employeeCertificate.findMany({
where: { employee_id: employeeId },
orderBy: [{ obtained_date: "desc" }, { created_at: "desc" }],
});
return sendData(reply, request, {
items: items.map((item) => mapEmployeeCertificate(item, fastify.config.appTimezone)),
});
});
fastify.post("/employees/:id/certificates", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(CERTIFICATE_CREATE_SCHEMA, request.body);
validateCertificateDates(body);
await ensureEmployeeExists(fastify.prisma, employeeId);
const certificate = await fastify.prisma.employeeCertificate.create({
data: {
employee_id: employeeId,
name: body.name,
issuer: body.issuer,
obtained_date: parseDateOnly(body.obtainedDate),
expiry_date: parseDateOnly(body.expiryDate ?? null),
certificate_no: body.certificateNo ?? null,
attachment_path: body.attachmentPath ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
return sendData(reply, request, mapEmployeeCertificate(certificate, fastify.config.appTimezone));
});
fastify.patch("/employees/:id/certificates/:certificateId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const certificateId = parseId(request.params?.certificateId);
const body = parseBody(CERTIFICATE_PATCH_SCHEMA, request.body);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeCertificateExists(fastify.prisma, employeeId, certificateId);
validateCertificateDates({
obtainedDate: body.obtainedDate ?? current.obtained_date?.toISOString?.()?.slice(0, 10),
expiryDate: body.expiryDate !== undefined ? body.expiryDate : current.expiry_date?.toISOString?.()?.slice(0, 10),
});
const certificate = await fastify.prisma.employeeCertificate.update({
where: { id: certificateId },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.issuer !== undefined ? { issuer: body.issuer } : {}),
...(body.obtainedDate !== undefined ? { obtained_date: parseDateOnly(body.obtainedDate) } : {}),
...(body.expiryDate !== undefined ? { expiry_date: parseDateOnly(body.expiryDate ?? null) } : {}),
...(body.certificateNo !== undefined ? { certificate_no: body.certificateNo ?? null } : {}),
...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}),
updated_by: request.employee.id,
},
});
return sendData(reply, request, mapEmployeeCertificate(certificate, fastify.config.appTimezone));
});
fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
@@ -871,7 +1358,10 @@ module.exports = employeesRoutes;
module.exports.__testables = {
buildEmployeeOrganization,
buildEmployeeProfile,
deriveEmployeeCertificateStatus,
deriveEmployeeContractStatus,
mapEmployeeCertificate,
mapEmployeeContract,
mapEmployeeTraining,
mapOrganizationNode,
};
+30 -1
查看文件
@@ -14,7 +14,7 @@ async function buildEmployeeStats(prisma, options = {}) {
const next30DateTo = addDays(today, 30);
const activeEmployeeWhere = { status: "active" };
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts] = await Promise.all([
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts, validCertificateHolders, expiringCertificates] = await Promise.all([
prisma.employee.groupBy({
by: ["employment_status"],
where: activeEmployeeWhere,
@@ -55,6 +55,29 @@ async function buildEmployeeStats(prisma, options = {}) {
employee: activeEmployeeWhere,
},
}),
prisma.employeeCertificate.groupBy({
by: ["employee_id"],
where: {
employee: activeEmployeeWhere,
OR: [
{ expiry_date: null },
{
expiry_date: {
gte: today,
},
},
],
},
}),
prisma.employeeCertificate.count({
where: {
employee: activeEmployeeWhere,
expiry_date: {
gte: today,
lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`),
},
},
}),
]);
const departmentMap = new Map(departments.map((item) => [item.id, item.name]));
@@ -88,6 +111,12 @@ async function buildEmployeeStats(prisma, options = {}) {
dateTo: next30DateTo.toISOString().slice(0, 10),
count: expiringContracts,
},
certificateSummary: {
dateFrom: todayString,
dateTo: next30DateTo.toISOString().slice(0, 10),
validHolderCount: validCertificateHolders.length,
expiringSoonCount: expiringCertificates,
},
};
}
@@ -0,0 +1,391 @@
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const CONTRACT_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildMultipartRequest({ filename, contentType, content }) {
const boundary = "----codex-employee-attachment-boundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]);
return {
payload: body,
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
},
};
}
function buildFeatureFlagRecord() {
return {
id: "flag-1",
flag_key: "employee_lifecycle_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-03-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-03-28T00:00:00.000Z"),
updated_by: null,
};
}
function buildEmployeeRow(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
phone: "13800000000",
email: "[email protected]",
status: "active",
employment_status: "active",
department_id: DEPARTMENT_ID,
title: "员工",
direct_manager_id: null,
hire_date: new Date("2026-01-10T00:00:00.000Z"),
probation_end_date: null,
last_working_date: null,
work_location: "上海",
emergency_contact_name: null,
emergency_contact_phone: null,
notes: null,
avatar_path: null,
archived_at: null,
roles: [],
department: { id: DEPARTMENT_ID, name: "行政部" },
direct_manager: null,
...overrides,
};
}
function buildContractRow(overrides = {}) {
return {
id: CONTRACT_ID,
employee_id: EMPLOYEE_ID,
contract_no: "LAB-001",
contract_type: "labor",
start_date: new Date("2026-01-01T00:00:00.000Z"),
end_date: new Date("2026-12-31T00:00:00.000Z"),
terminated_date: null,
attachment_path: null,
created_at: new Date("2026-01-01T00:00:00.000Z"),
created_by: "actor-1",
updated_at: new Date("2026-01-01T00:00:00.000Z"),
updated_by: "actor-1",
...overrides,
};
}
async function buildApp({
actor = { id: "actor-1", department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] },
employee = {},
contract = {},
} = {}) {
const app = Fastify();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r45-attachments-"));
const state = {
employee: buildEmployeeRow(employee),
contract: buildContractRow(contract),
};
const employeeUpdate = vi.fn(async ({ data }) => {
state.employee = {
...state.employee,
...(data.avatar_path !== undefined ? { avatar_path: data.avatar_path } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.employee;
});
const contractUpdate = vi.fn(async ({ data }) => {
state.contract = {
...state.contract,
...(data.attachment_path !== undefined ? { attachment_path: data.attachment_path } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.contract;
});
const contractDelete = vi.fn(async () => {
const deleted = state.contract;
state.contract = null;
return deleted;
});
const auditCreate = vi.fn().mockResolvedValue({});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
employeeLifecycleEnabled: true,
storageDir: tempDir,
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employeeRecord, permissionCode) => {
const permissionCodes = employeeRecord.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", {
featureFlag: {
findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()),
},
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? state.employee : null)),
update: employeeUpdate,
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeContract: {
findFirst: vi.fn(async ({ where }) => {
if (!state.contract) {
return null;
}
if (where.id === state.contract.id && where.employee_id === state.contract.employee_id) {
return state.contract;
}
return null;
}),
findMany: vi.fn(async () => (state.contract ? [state.contract] : [])),
update: contractUpdate,
delete: contractDelete,
create: vi.fn(),
count: vi.fn().mockResolvedValue(0),
},
employeeTraining: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
},
employeeCertificate: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
groupBy: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeChecklistTask: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeLifecycleEvent: {
findMany: vi.fn().mockResolvedValue([]),
},
assetCard: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
workflowTask: {
count: vi.fn().mockResolvedValue(0),
},
auditLog: {
create: auditCreate,
},
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? null,
},
});
}
return reply.status(500).send({
requestId: request.id,
error: {
code: error.code ?? "SYS_001",
message: error.message ?? "服务器内部错误",
details: error.details ?? null,
},
});
});
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 1, fileSize: 10 * 1024 * 1024 },
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, auditCreate, contractDelete, contractUpdate, employeeUpdate, state, tempDir };
}
describe("employee attachment and delete routes", () => {
it("uploads, replaces and downloads contract attachments for system.users", async () => {
const { app, state, tempDir } = await buildApp({
contract: {
attachment_path: `contracts/${CONTRACT_ID}.png`,
},
});
const oldAttachmentPath = path.join(tempDir, "contracts", `${CONTRACT_ID}.png`);
await fs.mkdir(path.dirname(oldAttachmentPath), { recursive: true });
await fs.writeFile(oldAttachmentPath, Buffer.from("old-image"));
const uploadResponse = await app.inject({
method: "PUT",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
...buildMultipartRequest({
filename: "labor.pdf",
contentType: "application/pdf",
content: "new-pdf",
}),
});
expect(uploadResponse.statusCode).toBe(200);
expect(uploadResponse.json().data).toMatchObject({
attachmentPath: `contracts/${CONTRACT_ID}.pdf`,
attachmentUrl: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
});
expect(state.contract.attachment_path).toBe(`contracts/${CONTRACT_ID}.pdf`);
expect(await fs.readFile(path.join(tempDir, "contracts", `${CONTRACT_ID}.pdf`), "utf8")).toBe("new-pdf");
await expect(fs.access(oldAttachmentPath)).rejects.toMatchObject({ code: "ENOENT" });
const downloadResponse = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
});
expect(downloadResponse.statusCode).toBe(200);
expect(downloadResponse.headers["content-type"]).toContain("application/pdf");
expect(downloadResponse.headers["content-disposition"]).toContain('filename="LAB-001.pdf"');
expect(downloadResponse.body).toBe("new-pdf");
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("rejects contract attachment upload for non-admin users", async () => {
const { app, tempDir } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "PUT",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
...buildMultipartRequest({
filename: "labor.pdf",
contentType: "application/pdf",
content: "new-pdf",
}),
});
expect(response.statusCode).toBe(403);
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("allows self-service avatar deletion and clears stored file", async () => {
const { app, state, tempDir } = await buildApp({
actor: { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
employee: { avatar_path: `avatars/${EMPLOYEE_ID}.webp` },
});
const avatarPath = path.join(tempDir, "avatars", `${EMPLOYEE_ID}.webp`);
await fs.mkdir(path.dirname(avatarPath), { recursive: true });
await fs.writeFile(avatarPath, Buffer.from("avatar"));
const response = await app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/avatar`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
employeeId: EMPLOYEE_ID,
avatarUrl: null,
});
expect(state.employee.avatar_path).toBeNull();
await expect(fs.access(avatarPath)).rejects.toMatchObject({ code: "ENOENT" });
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("rejects deleting another employee avatar without system.users", async () => {
const { app, tempDir } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/avatar`,
});
expect(response.statusCode).toBe(403);
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("rejects deleting active contracts and allows deleting expired contracts without local file", async () => {
const activeApp = await buildApp();
const activeResponse = await activeApp.app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}`,
});
expect(activeResponse.statusCode).toBe(409);
expect(activeResponse.json().error.code).toBe("VALID_001");
expect(activeApp.state.contract).not.toBeNull();
await activeApp.app.close();
await fs.rm(activeApp.tempDir, { recursive: true, force: true });
const expiredApp = await buildApp({
contract: {
end_date: new Date("2025-12-31T00:00:00.000Z"),
attachment_path: `contracts/${CONTRACT_ID}.pdf`,
},
});
const expiredResponse = await expiredApp.app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}`,
});
expect(expiredResponse.statusCode).toBe(200);
expect(expiredResponse.json().data).toEqual({
contractId: CONTRACT_ID,
deleted: true,
});
expect(expiredApp.state.contract).toBeNull();
expect(expiredApp.auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "EMPLOYEE_CONTRACT_DELETED",
target_id: CONTRACT_ID,
}),
}),
);
await expiredApp.app.close();
await fs.rm(expiredApp.tempDir, { recursive: true, force: true });
});
});
@@ -0,0 +1,232 @@
const Fastify = require("fastify");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildFeatureFlagRecord() {
return {
id: "flag-1",
flag_key: "employee_lifecycle_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-03-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-03-28T00:00:00.000Z"),
updated_by: null,
};
}
function buildCertificateRow(overrides = {}) {
return {
id: "44444444-4444-4444-8444-444444444444",
employee_id: EMPLOYEE_ID,
name: "PMP",
issuer: "PMI",
obtained_date: new Date("2025-01-01T00:00:00.000Z"),
expiry_date: null,
certificate_no: "CERT-001",
attachment_path: "certificates/pmp.pdf",
created_at: new Date("2025-01-01T00:00:00.000Z"),
created_by: "actor-1",
updated_at: new Date("2025-01-01T00:00:00.000Z"),
updated_by: "actor-1",
...overrides,
};
}
async function buildApp({ actor = { id: "actor-1", department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] } } = {}) {
const app = Fastify();
const state = {
certificate: null,
};
const certificateCreate = vi.fn(async ({ data }) => {
state.certificate = buildCertificateRow({
name: data.name,
issuer: data.issuer,
obtained_date: data.obtained_date,
expiry_date: data.expiry_date,
certificate_no: data.certificate_no,
attachment_path: data.attachment_path,
created_by: data.created_by,
updated_by: data.updated_by,
});
return state.certificate;
});
const certificateUpdate = vi.fn(async ({ data }) => {
state.certificate = {
...state.certificate,
...(data.name !== undefined ? { name: data.name } : {}),
...(data.issuer !== undefined ? { issuer: data.issuer } : {}),
...(data.obtained_date !== undefined ? { obtained_date: data.obtained_date } : {}),
...(data.expiry_date !== undefined ? { expiry_date: data.expiry_date } : {}),
...(data.certificate_no !== undefined ? { certificate_no: data.certificate_no } : {}),
...(data.attachment_path !== undefined ? { attachment_path: data.attachment_path } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.certificate;
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
employeeLifecycleEnabled: true,
storageDir: "/tmp",
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employeeRecord, permissionCode) => {
const permissionCodes = employeeRecord.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", {
featureFlag: {
findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()),
},
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? { id: EMPLOYEE_ID } : null)),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
},
employeeContract: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
employeeTraining: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
},
employeeCertificate: {
findMany: vi.fn(async () => (state.certificate ? [state.certificate] : [])),
findFirst: vi.fn(async ({ where }) => (state.certificate?.id === where.id && state.certificate?.employee_id === where.employee_id ? state.certificate : null)),
create: certificateCreate,
update: certificateUpdate,
groupBy: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeChecklistTask: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeLifecycleEvent: {
findMany: vi.fn().mockResolvedValue([]),
},
assetCard: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
workflowTask: {
count: vi.fn().mockResolvedValue(0),
},
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(employeesRoutes, { prefix: "/api" });
return { app, certificateCreate, certificateUpdate, state };
}
describe("employee certificate routes", () => {
it("allows system.users to create, update and list certificates with derived status", async () => {
const { app, certificateCreate, certificateUpdate, state } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: `/api/employees/${EMPLOYEE_ID}/certificates`,
payload: {
name: "PMP",
issuer: "PMI",
obtainedDate: "2025-01-01",
expiryDate: null,
certificateNo: "CERT-001",
attachmentPath: "certificates/pmp.pdf",
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json().data).toMatchObject({
name: "PMP",
issuer: "PMI",
status: "valid",
attachmentPath: "certificates/pmp.pdf",
});
expect(certificateCreate).toHaveBeenCalledTimes(1);
const patchResponse = await app.inject({
method: "PATCH",
url: `/api/employees/${EMPLOYEE_ID}/certificates/${state.certificate.id}`,
payload: {
expiryDate: "2025-02-01",
},
});
expect(patchResponse.statusCode).toBe(200);
expect(patchResponse.json().data).toMatchObject({
expiryDate: "2025-02-01",
status: "expired",
});
expect(certificateUpdate).toHaveBeenCalledTimes(1);
const listResponse = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/certificates`,
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([
expect.objectContaining({
id: state.certificate.id,
status: "expired",
}),
]);
await app.close();
});
it("rejects non-admin users accessing certificates", async () => {
const { app } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/certificates`,
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -96,6 +96,7 @@ describe("employee profile organization", () => {
name: true,
title: true,
status: true,
avatar_path: true,
department: { select: { id: true, name: true } },
},
});
@@ -183,6 +184,7 @@ describe("employee profile organization", () => {
name: "Helen",
title: "主管",
status: "active",
avatarUrl: null,
department: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
name: "行政部",
@@ -21,6 +21,12 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "研发部" },
]);
const contractCount = vi.fn().mockResolvedValue(2);
const certificateGroupBy = vi.fn().mockResolvedValue([
{ employee_id: "emp-1" },
{ employee_id: "emp-2" },
{ employee_id: "emp-3" },
]);
const certificateCount = vi.fn().mockResolvedValue(4);
app.decorate("prisma", {
employee: {
@@ -36,6 +42,11 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
count: stats?.employeeContractCount ?? contractCount,
findMany: vi.fn().mockResolvedValue([]),
},
employeeCertificate: {
groupBy: stats?.employeeCertificateGroupBy ?? certificateGroupBy,
count: stats?.employeeCertificateCount ?? certificateCount,
findMany: vi.fn().mockResolvedValue([]),
},
});
app.decorate("authenticate", async (request) => {
request.employee = {
@@ -68,12 +79,12 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount };
return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount, certificateGroupBy, certificateCount };
}
describe("employees stats route", () => {
it("returns aggregated employee stats for read-only viewers", async () => {
const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount } = await buildApp({
const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount, certificateGroupBy, certificateCount } = await buildApp({
permissionCodes: ["report.dashboard.view"],
});
@@ -92,10 +103,18 @@ describe("employees stats route", () => {
expect(payload.data.recentMovements.hires).toBe(3);
expect(payload.data.recentMovements.departures).toBe(1);
expect(payload.data.expiringContracts.count).toBe(2);
expect(payload.data.certificateSummary).toEqual({
dateFrom: payload.data.asOfDate,
dateTo: expect.any(String),
validHolderCount: 3,
expiringSoonCount: 4,
});
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
expect(employeeCount).toHaveBeenCalledTimes(2);
expect(departmentFindMany).toHaveBeenCalledTimes(1);
expect(contractCount).toHaveBeenCalledTimes(1);
expect(certificateGroupBy).toHaveBeenCalledTimes(1);
expect(certificateCount).toHaveBeenCalledTimes(1);
await app.close();
});
@@ -0,0 +1,234 @@
const Fastify = require("fastify");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildFeatureFlagRecord() {
return {
id: "flag-1",
flag_key: "employee_lifecycle_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-03-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-03-28T00:00:00.000Z"),
updated_by: null,
};
}
function buildTrainingRow(overrides = {}) {
return {
id: "33333333-3333-4333-8333-333333333333",
employee_id: EMPLOYEE_ID,
name: "入职培训",
training_type: "internal",
start_date: new Date("2026-03-01T00:00:00.000Z"),
end_date: new Date("2026-03-02T00:00:00.000Z"),
status: "planned",
notes: "首次培训",
created_at: new Date("2026-03-01T00:00:00.000Z"),
created_by: "actor-1",
updated_at: new Date("2026-03-01T00:00:00.000Z"),
updated_by: "actor-1",
...overrides,
};
}
async function buildApp({ actor = { id: "actor-1", department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] } } = {}) {
const app = Fastify();
const state = {
training: null,
};
const trainingCreate = vi.fn(async ({ data }) => {
state.training = buildTrainingRow({
name: data.name,
training_type: data.training_type,
start_date: data.start_date,
end_date: data.end_date,
status: data.status,
notes: data.notes,
created_by: data.created_by,
updated_by: data.updated_by,
});
return state.training;
});
const trainingUpdate = vi.fn(async ({ data }) => {
state.training = {
...state.training,
...(data.name !== undefined ? { name: data.name } : {}),
...(data.training_type !== undefined ? { training_type: data.training_type } : {}),
...(data.start_date !== undefined ? { start_date: data.start_date } : {}),
...(data.end_date !== undefined ? { end_date: data.end_date } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.notes !== undefined ? { notes: data.notes } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.training;
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
employeeLifecycleEnabled: true,
storageDir: "/tmp",
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employeeRecord, permissionCode) => {
const permissionCodes = employeeRecord.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", {
featureFlag: {
findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()),
},
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? { id: EMPLOYEE_ID } : null)),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
},
employeeContract: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
employeeTraining: {
findMany: vi.fn(async () => (state.training ? [state.training] : [])),
findFirst: vi.fn(async ({ where }) => (state.training?.id === where.id && state.training?.employee_id === where.employee_id ? state.training : null)),
create: trainingCreate,
update: trainingUpdate,
},
employeeCertificate: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
groupBy: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeChecklistTask: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeLifecycleEvent: {
findMany: vi.fn().mockResolvedValue([]),
},
assetCard: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
workflowTask: {
count: vi.fn().mockResolvedValue(0),
},
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(employeesRoutes, { prefix: "/api" });
return { app, state, trainingCreate, trainingUpdate };
}
describe("employee training routes", () => {
it("allows system.users to create, update and list employee trainings", async () => {
const { app, state, trainingCreate, trainingUpdate } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: `/api/employees/${EMPLOYEE_ID}/trainings`,
payload: {
name: "安全培训",
trainingType: "external",
startDate: "2026-03-10",
endDate: "2026-03-12",
status: "planned",
notes: "第一阶段",
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json().data).toMatchObject({
name: "安全培训",
trainingType: "external",
status: "planned",
notes: "第一阶段",
});
expect(trainingCreate).toHaveBeenCalledTimes(1);
const patchResponse = await app.inject({
method: "PATCH",
url: `/api/employees/${EMPLOYEE_ID}/trainings/${state.training.id}`,
payload: {
status: "completed",
endDate: "2026-03-15",
},
});
expect(patchResponse.statusCode).toBe(200);
expect(patchResponse.json().data).toMatchObject({
status: "completed",
endDate: "2026-03-15",
});
expect(trainingUpdate).toHaveBeenCalledTimes(1);
const listResponse = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/trainings`,
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([
expect.objectContaining({
id: state.training.id,
name: "安全培训",
status: "completed",
}),
]);
await app.close();
});
it("rejects non-admin users managing trainings", async () => {
const { app } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/trainings`,
});
expect(response.statusCode).toBe(403);
await app.close();
});
});