R45 A2-A7 员工附件与培训证书管理
这个提交包含在:
+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" },
|
||||
|
||||
@@ -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>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingContract(contract)}>
|
||||
编辑
|
||||
</Button>
|
||||
<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"
|
||||
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}>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 新模型、文件删除操作、受限下载链路和删除安全性。"
|
||||
|
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户