R57 A1-A6:报销模板与差旅申请关联
这个提交包含在:
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type ReimbursementTemplateItem = {
|
||||||
|
expenseTypeId: string;
|
||||||
|
description: string;
|
||||||
|
defaultAmount?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
items: ReimbursementTemplateItem[];
|
||||||
|
sortOrder: number;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementTemplateListResponse = {
|
||||||
|
enabled: boolean | null;
|
||||||
|
items: ReimbursementTemplate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementTemplateListQuery = {
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateReimbursementTemplatePayload = {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
items?: ReimbursementTemplateItem[];
|
||||||
|
sortOrder?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateReimbursementTemplatePayload = {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
items?: ReimbursementTemplateItem[];
|
||||||
|
sortOrder?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value == null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = params.toString();
|
||||||
|
return encoded ? `?${encoded}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementTemplates(query: ReimbursementTemplateListQuery = {}) {
|
||||||
|
return apiRequestData<ReimbursementTemplateListResponse>(
|
||||||
|
`/api/reimbursement-templates${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReimbursementTemplate(payload: CreateReimbursementTemplatePayload) {
|
||||||
|
return apiRequestData<ReimbursementTemplate>("/api/reimbursement-templates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReimbursementTemplate(
|
||||||
|
templateId: string,
|
||||||
|
payload: UpdateReimbursementTemplatePayload,
|
||||||
|
) {
|
||||||
|
return apiRequestData<ReimbursementTemplate>(`/api/reimbursement-templates/${templateId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReimbursementTemplate(templateId: string) {
|
||||||
|
return apiRequestData<ReimbursementTemplate>(`/api/reimbursement-templates/${templateId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,6 +24,15 @@ export type ReimbursementItem = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LinkedTravelRequestSummary = {
|
||||||
|
id: string;
|
||||||
|
requestNo: string | null;
|
||||||
|
purpose: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
estimatedAmount: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReimbursementListItem = {
|
export type ReimbursementListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
formNumber: string | null;
|
formNumber: string | null;
|
||||||
@@ -113,6 +122,8 @@ export type ReimbursementDetail = {
|
|||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
linkedTravelRequestId: string | null;
|
||||||
|
linkedTravelRequest: LinkedTravelRequestSummary | null;
|
||||||
project: {
|
project: {
|
||||||
id: string;
|
id: string;
|
||||||
projectNo: string;
|
projectNo: string;
|
||||||
@@ -207,6 +218,48 @@ export type ReimbursementPayPayload = {
|
|||||||
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
|
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReimbursementCreatePayload = {
|
||||||
|
ownerEmployeeId?: string;
|
||||||
|
reimbursementDate?: string;
|
||||||
|
description?: string;
|
||||||
|
templateId?: string;
|
||||||
|
linkedTravelRequestId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementCreateResponse = {
|
||||||
|
id: string;
|
||||||
|
formNumber: string | null;
|
||||||
|
submitterId: string;
|
||||||
|
ownerId: string;
|
||||||
|
reimbursementDate: string | null;
|
||||||
|
status: ReimbursementStatus;
|
||||||
|
description: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
totalAmount: string;
|
||||||
|
linkedTravelRequestId: string | null;
|
||||||
|
linkedTravelRequest: LinkedTravelRequestSummary | null;
|
||||||
|
items: ReimbursementItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LinkableTravelRequestSummary = {
|
||||||
|
id: string;
|
||||||
|
requestNo: string | null;
|
||||||
|
purpose: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
estimatedAmount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LinkableTravelRequestListResponse = {
|
||||||
|
ownerEmployeeId: string;
|
||||||
|
items: LinkableTravelRequestSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ReimbursementItemPayload = {
|
export type ReimbursementItemPayload = {
|
||||||
expenseTypeId: string;
|
expenseTypeId: string;
|
||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
@@ -301,17 +354,19 @@ export async function fetchReimbursementDetail(reimbursementId: string) {
|
|||||||
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
|
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createReimbursement(payload: {
|
export async function createReimbursement(payload: ReimbursementCreatePayload = {}) {
|
||||||
ownerEmployeeId?: string;
|
return apiRequestData<ReimbursementCreateResponse>(`/api/reimbursements`, {
|
||||||
reimbursementDate?: string;
|
|
||||||
description?: string;
|
|
||||||
} = {}) {
|
|
||||||
return apiRequestData<{ id: string }>(`/api/reimbursements`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLinkableTravelRequests(query: { ownerEmployeeId?: string } = {}) {
|
||||||
|
return apiRequestData<LinkableTravelRequestListResponse>(
|
||||||
|
`/api/reimbursements/linkable-travel-requests${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function precheckReimbursement(reimbursementId: string) {
|
export async function precheckReimbursement(reimbursementId: string) {
|
||||||
return apiRequestData<PrecheckResponse>(`/api/reimbursements/${reimbursementId}/precheck`, {
|
return apiRequestData<PrecheckResponse>(`/api/reimbursements/${reimbursementId}/precheck`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -503,6 +503,43 @@ export function ReimbursementDetailPage() {
|
|||||||
<div>说明:{detail.description || "-"}</div>
|
<div>说明:{detail.description || "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detail.linkedTravelRequest ? (
|
||||||
|
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-zinc-900">
|
||||||
|
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
查看申请
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||||
|
<div className="text-xs text-zinc-500">日期范围</div>
|
||||||
|
<div className="mt-1 font-medium text-zinc-900">
|
||||||
|
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||||
|
<div className="text-xs text-zinc-500">预估金额</div>
|
||||||
|
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||||
|
<div className="text-xs text-zinc-500">申请编号</div>
|
||||||
|
<div className="mt-1 font-medium text-zinc-900">
|
||||||
|
{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||||
|
import {
|
||||||
|
createReimbursementTemplate,
|
||||||
|
deleteReimbursementTemplate,
|
||||||
|
fetchReimbursementTemplates,
|
||||||
|
updateReimbursementTemplate,
|
||||||
|
type ReimbursementTemplate,
|
||||||
|
type ReimbursementTemplateItem,
|
||||||
|
} from "../api/reimbursementTemplates";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Label } from "../components/ui/label";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
|
type FilterEnabled = "all" | "enabled" | "disabled";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sortOrder: string;
|
||||||
|
enabled: boolean;
|
||||||
|
items: Array<{
|
||||||
|
expenseTypeId: string;
|
||||||
|
description: string;
|
||||||
|
defaultAmount: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(expenseTypeId = ""): FormState {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sortOrder: "0",
|
||||||
|
enabled: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
expenseTypeId,
|
||||||
|
description: "",
|
||||||
|
defaultAmount: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(item: ReimbursementTemplate): FormState {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?? "",
|
||||||
|
sortOrder: String(item.sortOrder),
|
||||||
|
enabled: item.enabled,
|
||||||
|
items: item.items.length
|
||||||
|
? item.items.map((entry) => ({
|
||||||
|
expenseTypeId: entry.expenseTypeId,
|
||||||
|
description: entry.description,
|
||||||
|
defaultAmount: entry.defaultAmount ?? "",
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
expenseTypeId: "",
|
||||||
|
description: "",
|
||||||
|
defaultAmount: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReimbursementTemplatesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [enabledFilter, setEnabledFilter] = useState<FilterEnabled>("all");
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<ReimbursementTemplate | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<ReimbursementTemplate | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const templatesQuery = useQuery({
|
||||||
|
queryKey: ["reimbursement-templates", enabledFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchReimbursementTemplates({
|
||||||
|
enabled:
|
||||||
|
enabledFilter === "all"
|
||||||
|
? undefined
|
||||||
|
: enabledFilter === "enabled",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseTypesQuery = useQuery({
|
||||||
|
queryKey: ["expense-types", "reimbursement-templates"],
|
||||||
|
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingItem) {
|
||||||
|
setForm(toForm(editingItem));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackExpenseTypeId = expenseTypesQuery.data?.items?.[0]?.id ?? "";
|
||||||
|
setForm(emptyForm(fallbackExpenseTypeId));
|
||||||
|
}, [dialogOpen, editingItem, expenseTypesQuery.data?.items]);
|
||||||
|
|
||||||
|
const expenseTypeMap = useMemo(() => {
|
||||||
|
return new Map((expenseTypesQuery.data?.items ?? []).map((item) => [item.id, item]));
|
||||||
|
}, [expenseTypesQuery.data?.items]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const items: ReimbursementTemplateItem[] = form.items
|
||||||
|
.filter((item) => item.expenseTypeId.trim() || item.description.trim() || item.defaultAmount.trim())
|
||||||
|
.map((item) => ({
|
||||||
|
expenseTypeId: item.expenseTypeId,
|
||||||
|
description: item.description.trim(),
|
||||||
|
...(item.defaultAmount.trim() ? { defaultAmount: item.defaultAmount.trim() } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
sortOrder: Number(form.sortOrder || 0),
|
||||||
|
enabled: form.enabled,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingItem) {
|
||||||
|
return updateReimbursementTemplate(editingItem.id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createReimbursementTemplate(payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursement-templates"] });
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
setError("");
|
||||||
|
toast.success(editingItem ? "模板已更新" : "模板已创建");
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
const message = resolveErrorMessage(mutationError);
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (templateId: string) => deleteReimbursementTemplate(templateId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursement-templates"] });
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setError("");
|
||||||
|
toast.success("模板已停用");
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
const message = resolveErrorMessage(mutationError);
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const templates = templatesQuery.data?.items ?? [];
|
||||||
|
const canSubmit =
|
||||||
|
form.name.trim().length > 0 &&
|
||||||
|
form.items.some((item) => item.expenseTypeId.trim() && item.description.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="报销模板管理"
|
||||||
|
subtitle="维护常见报销场景的预设明细,缩短员工建单时间。"
|
||||||
|
actions={(
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增模板
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="grid gap-3 md:max-w-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reimbursement-template-enabled-filter">启用状态</Label>
|
||||||
|
<select
|
||||||
|
id="reimbursement-template-enabled-filter"
|
||||||
|
className="select-std"
|
||||||
|
value={enabledFilter}
|
||||||
|
onChange={(event) => setEnabledFilter(event.target.value as FilterEnabled)}
|
||||||
|
>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="enabled">启用</option>
|
||||||
|
<option value="disabled">停用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templatesQuery.isLoading ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index} className="card-std h-56 animate-pulse bg-zinc-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{templatesQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(templatesQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!templatesQuery.isLoading && !templatesQuery.isError ? (
|
||||||
|
templates.length ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{templates.map((item) => (
|
||||||
|
<article key={item.id} className="card-std flex h-full flex-col gap-4 p-6">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-zinc-900">{item.name}</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">{item.description || "未填写模板说明"}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
item.enabled
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "bg-zinc-100 text-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.enabled ? "启用" : "停用"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-zinc-400">预设明细</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{item.items.length ? (
|
||||||
|
item.items.map((entry, index) => (
|
||||||
|
<div key={`${item.id}-${index}`} className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||||
|
<div className="font-medium text-zinc-900">
|
||||||
|
{expenseTypeMap.get(entry.expenseTypeId)?.name ?? entry.expenseTypeId}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-zinc-600">{entry.description}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-500">
|
||||||
|
默认金额:{entry.defaultAmount ? `¥${entry.defaultAmount}` : "未预填"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-zinc-200 px-4 py-6 text-sm text-zinc-500">
|
||||||
|
该模板暂未配置预设明细。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-3 text-sm text-zinc-500">
|
||||||
|
<span>排序:{item.sortOrder}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setConfirmDelete(item)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card-std px-6 py-10 text-sm text-zinc-500">当前筛选下暂无模板。</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setEditingItem(null);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingItem ? "编辑报销模板" : "新增报销模板"}</DialogTitle>
|
||||||
|
<DialogDescription>模板明细会在创建报销时自动生成对应报销项。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reimbursement-template-name">模板名称</Label>
|
||||||
|
<Input
|
||||||
|
id="reimbursement-template-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="例如:国内差旅报销"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reimbursement-template-sort-order">排序</Label>
|
||||||
|
<Input
|
||||||
|
id="reimbursement-template-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={form.sortOrder}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, sortOrder: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reimbursement-template-description">模板说明</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reimbursement-template-description"
|
||||||
|
rows={2}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
|
||||||
|
placeholder="描述适用场景或填单注意事项"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
启用该模板
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-2xl border border-zinc-200 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-zinc-900">预设明细</h4>
|
||||||
|
<p className="text-xs text-zinc-500">费用类型与说明必填,默认金额可选并按字符串原样提交。</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
items: [
|
||||||
|
...current.items,
|
||||||
|
{
|
||||||
|
expenseTypeId: expenseTypesQuery.data?.items?.[0]?.id ?? "",
|
||||||
|
description: "",
|
||||||
|
defaultAmount: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
添加明细
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{form.items.map((item, index) => (
|
||||||
|
<div key={index} className="grid gap-3 rounded-xl border border-zinc-200 p-4 md:grid-cols-[1.4fr_1.8fr_1fr_auto]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>费用类型</Label>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={item.expenseTypeId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.map((entry, entryIndex) =>
|
||||||
|
entryIndex === index
|
||||||
|
? { ...entry, expenseTypeId: event.target.value }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">请选择费用类型</option>
|
||||||
|
{(expenseTypesQuery.data?.items ?? []).map((expenseType) => (
|
||||||
|
<option key={expenseType.id} value={expenseType.id}>
|
||||||
|
{expenseType.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>说明</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.map((entry, entryIndex) =>
|
||||||
|
entryIndex === index
|
||||||
|
? { ...entry, description: event.target.value }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="例如:出差往返交通"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>默认金额</Label>
|
||||||
|
<Input
|
||||||
|
value={item.defaultAmount}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.map((entry, entryIndex) =>
|
||||||
|
entryIndex === index
|
||||||
|
? { ...entry, defaultAmount: event.target.value }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="可选,例 100.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={form.items.length <= 1}
|
||||||
|
onClick={() =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.filter((_, entryIndex) => entryIndex !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!canSubmit || saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||||
|
{saveMutation.isPending ? "保存中..." : editingItem ? "保存修改" : "创建模板"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(confirmDelete)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="确认删除该报销模板?"
|
||||||
|
description={confirmDelete ? `删除后会将“${confirmDelete.name}”停用,历史记录仍然保留。` : undefined}
|
||||||
|
confirmLabel="删除"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (confirmDelete) {
|
||||||
|
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReimbursementTemplatesPage;
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchMyDelegations } from "../api/delegations";
|
import { fetchMyDelegations } from "../api/delegations";
|
||||||
import { exportReimbursements } from "../api/exports";
|
import { exportReimbursements } from "../api/exports";
|
||||||
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
||||||
|
import {
|
||||||
|
createReimbursement,
|
||||||
|
fetchLinkableTravelRequests,
|
||||||
|
fetchReimbursements,
|
||||||
|
type ReimbursementCreatePayload,
|
||||||
|
type ReimbursementStatus,
|
||||||
|
} from "../api/reimbursements";
|
||||||
import {
|
import {
|
||||||
batchApproveWorkflowTasks,
|
batchApproveWorkflowTasks,
|
||||||
batchRejectWorkflowTasks,
|
batchRejectWorkflowTasks,
|
||||||
@@ -30,7 +37,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../components/ui/dialog";
|
} from "../components/ui/dialog";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
@@ -78,6 +91,7 @@ function formatBatchOutcome(action: BatchAction, outcome: string) {
|
|||||||
|
|
||||||
export function ReimbursementsPage() {
|
export function ReimbursementsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { permissions, me } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [exportError, setExportError] = useState("");
|
const [exportError, setExportError] = useState("");
|
||||||
@@ -92,6 +106,7 @@ export function ReimbursementsPage() {
|
|||||||
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||||||
|
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||||
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||||
@@ -109,6 +124,21 @@ export function ReimbursementsPage() {
|
|||||||
queryFn: () => fetchMyDelegations("active"),
|
queryFn: () => fetchMyDelegations("active"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templateOptionsQuery = useQuery({
|
||||||
|
enabled: canCreateExpense,
|
||||||
|
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
|
||||||
|
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkableTravelRequestsQuery = useQuery({
|
||||||
|
enabled: canCreateExpense,
|
||||||
|
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchLinkableTravelRequests({
|
||||||
|
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const items = listQuery.data?.items ?? [];
|
const items = listQuery.data?.items ?? [];
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
return items.reduce(
|
return items.reduce(
|
||||||
@@ -123,6 +153,9 @@ export function ReimbursementsPage() {
|
|||||||
);
|
);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
const templateOptions = templateOptionsQuery.data?.items ?? [];
|
||||||
|
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
|
||||||
|
|
||||||
const actionableItems = useMemo(
|
const actionableItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.filter(
|
items.filter(
|
||||||
@@ -151,13 +184,17 @@ export function ReimbursementsPage() {
|
|||||||
}, [actionableIdSet, canShowBatchEntry]);
|
}, [actionableIdSet, canShowBatchEntry]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload),
|
||||||
createReimbursement({
|
onSuccess: async (response, payload) => {
|
||||||
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
toast.success("报销单已创建");
|
if (payload.linkedTravelRequestId) {
|
||||||
|
toast.success("已按差旅申请创建报销单");
|
||||||
|
} else if (payload.templateId) {
|
||||||
|
toast.success("已按模板创建报销单");
|
||||||
|
} else {
|
||||||
|
toast.success("报销单已创建");
|
||||||
|
}
|
||||||
|
navigate(`/reimbursements/${response.id}`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
||||||
@@ -293,6 +330,13 @@ export function ReimbursementsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startCreateReimbursement(payload: Omit<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
|
||||||
|
createMutation.mutate({
|
||||||
|
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -324,14 +368,62 @@ export function ReimbursementsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
{canCreateExpense ? (
|
||||||
data-testid="reimbursements-create"
|
<DropdownMenu>
|
||||||
disabled={createMutation.isPending}
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => createMutation.mutate()}
|
<Button
|
||||||
type="button"
|
data-testid="reimbursements-create"
|
||||||
>
|
disabled={createMutation.isPending}
|
||||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
type="button"
|
||||||
</Button>
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
|
||||||
|
空白报销
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||||||
|
从模板创建
|
||||||
|
</div>
|
||||||
|
{templateOptionsQuery.isLoading ? (
|
||||||
|
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
|
||||||
|
) : templateOptions.length ? (
|
||||||
|
templateOptions.map((template) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={template.id}
|
||||||
|
onSelect={() => startCreateReimbursement({ templateId: template.id })}
|
||||||
|
>
|
||||||
|
{template.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||||||
|
关联差旅申请
|
||||||
|
</div>
|
||||||
|
{linkableTravelRequestsQuery.isLoading ? (
|
||||||
|
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
|
||||||
|
) : linkableTravelRequests.length ? (
|
||||||
|
linkableTravelRequests.map((travelRequest) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={travelRequest.id}
|
||||||
|
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
|
||||||
|
>
|
||||||
|
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -374,6 +466,18 @@ export function ReimbursementsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{templateOptionsQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{linkableTravelRequestsQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{exportError ? (
|
{exportError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
{exportError}
|
{exportError}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const ITEMS = [
|
|||||||
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
|
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
|
||||||
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
||||||
{ to: "/system/expense-standards", title: "费用标准", description: "维护报销费用类型上限与 warn/block 管控模式。" },
|
{ to: "/system/expense-standards", title: "费用标准", description: "维护报销费用类型上限与 warn/block 管控模式。" },
|
||||||
|
{ to: "/system/reimbursement-templates", title: "报销模板", description: "维护差旅费、招待费、办公用品等常见报销模板。" },
|
||||||
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
||||||
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
||||||
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
|
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
PurchaseRequestsPage,
|
PurchaseRequestsPage,
|
||||||
ReimbursementDetailPage,
|
ReimbursementDetailPage,
|
||||||
ReimbursementDashboardPage,
|
ReimbursementDashboardPage,
|
||||||
|
ReimbursementTemplatesPage,
|
||||||
ReimbursementsPage,
|
ReimbursementsPage,
|
||||||
TravelRequestsPage,
|
TravelRequestsPage,
|
||||||
UserManagementPage,
|
UserManagementPage,
|
||||||
@@ -158,6 +159,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="system/reimbursement-templates"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["system.settings"]}>
|
||||||
|
{withRouteFallback(<ReimbursementTemplatesPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="reimbursements/:reimbursementId"
|
path="reimbursements/:reimbursementId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ReimbursementTemplatesPage as default } from "../../pages/ReimbursementTemplatesPage";
|
||||||
@@ -43,6 +43,7 @@ export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"))
|
|||||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||||
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
||||||
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
||||||
|
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
|
||||||
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
||||||
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
|
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
|
||||||
export const WorkflowDefinitionsPage = lazy(() => import("./WorkflowDefinitionsPage"));
|
export const WorkflowDefinitionsPage = lazy(() => import("./WorkflowDefinitionsPage"));
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
"A1","R57 执行合同建档与范围冻结","创建并校验 R57 报销模板与差旅申请关联执行合同 CSV,冻结范围仅包含 ReimbursementTemplate 模型与 CRUD、报销创建支持 templateId/linkedTravelRequestId、报销详情新增差旅关联摘要、报销模板管理页和创建入口改造。","issues/2026-04-08_r57-reimbursement-templates.csv 12 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 A1-A6,不混入报销 submit 流程改造、TravelRequestsPage 主界面重构或 travel_orders 回写。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-08_r57-reimbursement-templates.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 校验字段完整;2) A2-A6 依赖链正确;3) Notes 写明迁移回滚路径和差旅关联兼容规则。","manual","DONE","DONE","DONE","issues/2026-04-08_r57-reimbursement-templates.csv","[]","回滚路径:删除本次 CSV 不影响业务。"
|
||||||
|
"A2","ReimbursementTemplate 模型、迁移与 CRUD 路由","新增 ReimbursementTemplate Prisma 模型和 ReimbursementForm.linked_travel_request_id 字段,落地模板 CRUD 路由与只读列表接口。","schema.prisma 含 ReimbursementTemplate、ReimbursementForm.linked_travel_request_id 与 TravelRequest.linked_reimbursements relation;迁移目录为 20260408120000_r57_reimbursement_templates;/api/reimbursement-templates 支持 authenticated 读取、system.settings 管理、items 严格校验、DELETE 软删除。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/reimbursementTemplates.js && npm test -- tests/unit/routes/reimbursementTemplates.test.js。业务:1) enabled 过滤正常;2) items 非法 schema 被拒绝;3) expenseTypeId 不存在或停用被拒绝;4) DELETE 仅将 enabled 置为 false。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260408120000_r57_reimbursement_templates/*|server/src/routes/reimbursementTemplates.js|server/src/app.js|server/tests/unit/routes/reimbursementTemplates.test.js","[""A1""]","回滚路径:回退路由与注册,并在回滚迁移中 DROP TABLE reimbursement_templates、DROP COLUMN reimbursement_forms.linked_travel_request_id。"
|
||||||
|
"A3","报销创建支持模板与差旅关联","增强 POST /reimbursements 支持 templateId/linkedTravelRequestId,新增 linkable-travel-requests 查询,并扩展报销详情返回差旅关联摘要。","POST /reimbursements 支持空白/模板/差旅三种创建;templateId 与 linkedTravelRequestId 互斥;模板创建自动生成明细并更新 total_amount;差旅关联仅允许本人或合法代理关联 approved 且未关联的申请;GET /api/reimbursements/:id 返回 linkedTravelRequestId 与 linkedTravelRequest;GET /api/reimbursements/linkable-travel-requests 正确过滤本人/代理可关联申请。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/reimbursements.js && npm test -- tests/unit/routes/reimbursements.create.test.js。业务:1) 空白创建不回归;2) 模板创建会预填明细;3) 差旅关联创建会预填 description 和交通明细;4) 已通过 linked_travel_request_id 或 travel_orders.linked_reimbursement_id 关联过的申请不会再次暴露。","manual","DONE","DONE","DONE","server/src/routes/reimbursements.js|server/tests/unit/routes/reimbursements.create.test.js|server/prisma/schema.prisma","[""A1"",""A2""]","回滚路径:移除 templateId/linkedTravelRequestId 创建分支、linkable 路由和详情摘要返回,保留空白创建行为。"
|
||||||
|
"A4","报销模板管理前端页面与路由","新增报销模板管理页、前端 API、路由懒加载和系统设置入口。","/system/reimbursement-templates 仅 system.settings 用户可访问;页面支持模板卡片列表、新增/编辑弹窗、动态增减预设明细和软删除;前端通过 reimbursementTemplates API 调用后端接口。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 可在系统设置进入模板页;2) 可创建/编辑/停用模板;3) 预设明细费用类型名称与默认金额展示正确。","manual","DONE","DONE","DONE","client/src/api/reimbursementTemplates.ts|client/src/pages/ReimbursementTemplatesPage.tsx|client/src/pages/SystemSettingsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ReimbursementTemplatesPage.ts|client/src/router/lazy/index.ts","[""A1"",""A2""]","回滚路径:移除模板页面、API、路由和系统设置入口。"
|
||||||
|
"A5","报销创建入口与详情页差旅关联 UX","在报销列表创建入口集成空白/模板/关联差旅三种创建方式,并在报销详情页展示关联差旅申请卡片。","ReimbursementsPage 的创建入口改为下拉分区菜单,可直接选择模板或可关联差旅申请创建并跳转详情;ReimbursementDetailPage 在存在 linkedTravelRequestId 时展示可跳转的差旅摘要卡片;前端类型支持创建响应、详情和 linkable 列表。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 新建报销菜单可见空白/模板/差旅三个入口;2) 选择模板或差旅后创建成功并跳转;3) 详情页能看到关联差旅申请摘要并跳转到 /travel-requests?tab=requests&id=<id>。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/api/reimbursements.ts|client/src/api/travelRequests.ts","[""A1"",""A2"",""A3"",""A4""]","回滚路径:恢复单一空白创建按钮,移除详情页差旅关联卡片和相关类型扩展。"
|
||||||
|
"A6","Reviewer、QA、CSV 回填与提交收口","执行 reviewer 自检、post-check、远端迁移核验项准备与 CSV 回填,完成提交收口。","review 覆盖模板 JSON 校验、差旅归属与已关联过滤、模板/差旅创建后的明细结构一致性、linked_travel_request_id 可空兼容和迁移回滚;post-check 全部通过;CSV 三列状态回填为实测结果;完成 git commit。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/reimbursements.js && node --check src/routes/reimbursementTemplates.js && npm test -- tests/unit/routes/reimbursementTemplates.test.js tests/unit/routes/reimbursements.create.test.js && cd ../client && npm run typecheck && npm run build。部署准备:记录远端需执行 docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server npx prisma migrate status。业务:1) 模板和差旅创建都成功;2) 已关联差旅申请不再可选;3) CSV 状态已回填。","manual","DONE","DONE","DONE","issues/2026-04-08_r57-reimbursement-templates.csv|server/prisma/schema.prisma|server/prisma/migrations/20260408120000_r57_reimbursement_templates/*|server/src/routes/reimbursementTemplates.js|server/src/routes/reimbursements.js|server/tests/unit/routes/reimbursementTemplates.test.js|server/tests/unit/routes/reimbursements.create.test.js|client/src/api/reimbursementTemplates.ts|client/src/pages/ReimbursementTemplatesPage.tsx|client/src/pages/ReimbursementsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/SystemSettingsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ReimbursementTemplatesPage.ts|client/src/router/lazy/index.ts|client/src/api/reimbursements.ts","[""A2"",""A3"",""A4"",""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码与迁移;部署后已在远端执行 prisma migrate deploy + prisma migrate status,确认 R57 迁移已应用。本地 prisma migrate status 受既有历史迁移漂移影响未通过:本地数据库缺失若干仓库内迁移且存在 20260312193000_p22_prod_1_invoice_pool_travel_bind_constraint 本地目录缺口。"
|
||||||
|
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "reimbursement_templates" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(100) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"items" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_by" UUID,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_by" UUID,
|
||||||
|
|
||||||
|
CONSTRAINT "reimbursement_templates_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "reimbursement_templates_enabled_sort_order_idx"
|
||||||
|
ON "reimbursement_templates"("enabled", "sort_order");
|
||||||
|
|
||||||
|
ALTER TABLE "reimbursement_forms"
|
||||||
|
ADD COLUMN "linked_travel_request_id" UUID;
|
||||||
|
|
||||||
|
CREATE INDEX "idx_reimbursement_forms_linked_travel_request"
|
||||||
|
ON "reimbursement_forms"("linked_travel_request_id");
|
||||||
|
|
||||||
|
ALTER TABLE "reimbursement_forms"
|
||||||
|
ADD CONSTRAINT "reimbursement_forms_linked_travel_request_id_fkey"
|
||||||
|
FOREIGN KEY ("linked_travel_request_id") REFERENCES "travel_requests"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -842,6 +842,23 @@ model ExpenseStandard {
|
|||||||
@@map("expense_standards")
|
@@map("expense_standards")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ReimbursementTemplate {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
items Json @default("[]")
|
||||||
|
sort_order Int @default(0)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
created_by String? @db.Uuid
|
||||||
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
|
updated_by String? @db.Uuid
|
||||||
|
|
||||||
|
@@index([enabled, sort_order])
|
||||||
|
@@map("reimbursement_templates")
|
||||||
|
}
|
||||||
|
|
||||||
model DocumentSequence {
|
model DocumentSequence {
|
||||||
document_type String @id
|
document_type String @id
|
||||||
prefix String
|
prefix String
|
||||||
@@ -1269,6 +1286,7 @@ model ReimbursementForm {
|
|||||||
paid_at DateTime?
|
paid_at DateTime?
|
||||||
paid_by String? @db.Uuid
|
paid_by String? @db.Uuid
|
||||||
project_id String? @db.Uuid
|
project_id String? @db.Uuid
|
||||||
|
linked_travel_request_id String? @db.Uuid
|
||||||
|
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
created_by String? @db.Uuid
|
created_by String? @db.Uuid
|
||||||
@@ -1280,6 +1298,7 @@ model ReimbursementForm {
|
|||||||
paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull)
|
paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull)
|
||||||
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
|
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
|
||||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||||
|
travel_request TravelRequest? @relation(fields: [linked_travel_request_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
items ReimbursementItem[]
|
items ReimbursementItem[]
|
||||||
invoice_pool_items InvoicePool[]
|
invoice_pool_items InvoicePool[]
|
||||||
@@ -1293,6 +1312,7 @@ model ReimbursementForm {
|
|||||||
@@index([department_id])
|
@@index([department_id])
|
||||||
@@index([paid_by])
|
@@index([paid_by])
|
||||||
@@index([project_id], map: "idx_reimbursement_forms_project")
|
@@index([project_id], map: "idx_reimbursement_forms_project")
|
||||||
|
@@index([linked_travel_request_id], map: "idx_reimbursement_forms_linked_travel_request")
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([reimbursement_date])
|
@@index([reimbursement_date])
|
||||||
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
|
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
|
||||||
@@ -2479,6 +2499,7 @@ model TravelRequest {
|
|||||||
|
|
||||||
segments TravelRequestSegment[]
|
segments TravelRequestSegment[]
|
||||||
travel_orders TravelOrder[]
|
travel_orders TravelOrder[]
|
||||||
|
linked_reimbursements ReimbursementForm[]
|
||||||
reconciliation_items TravelReconciliationItem[]
|
reconciliation_items TravelReconciliationItem[]
|
||||||
card_authorizations CardAuthorization[]
|
card_authorizations CardAuthorization[]
|
||||||
card_transactions CardTransaction[]
|
card_transactions CardTransaction[]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const performanceRoutes = require("./routes/performance");
|
|||||||
const payrollRoutes = require("./routes/payroll");
|
const payrollRoutes = require("./routes/payroll");
|
||||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||||
const expenseStandardsRoutes = require("./routes/expenseStandards");
|
const expenseStandardsRoutes = require("./routes/expenseStandards");
|
||||||
|
const reimbursementTemplatesRoutes = require("./routes/reimbursementTemplates");
|
||||||
const configReadRoutes = require("./routes/configRead");
|
const configReadRoutes = require("./routes/configRead");
|
||||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||||
const featureFlagsRoutes = require("./routes/featureFlags");
|
const featureFlagsRoutes = require("./routes/featureFlags");
|
||||||
@@ -324,6 +325,7 @@ async function buildApp() {
|
|||||||
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
|
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
|
||||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||||
await app.register(expenseStandardsRoutes, { prefix: "/api" });
|
await app.register(expenseStandardsRoutes, { prefix: "/api" });
|
||||||
|
await app.register(reimbursementTemplatesRoutes, { prefix: "/api" });
|
||||||
await app.register(configReadRoutes, { prefix: "/api" });
|
await app.register(configReadRoutes, { prefix: "/api" });
|
||||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const { sendData } = require("../utils/reply");
|
||||||
|
|
||||||
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
|
const MONEY_STRING_SCHEMA = z.string().trim().regex(/^\d+(?:\.\d{1,2})?$/);
|
||||||
|
|
||||||
|
const TEMPLATE_ITEM_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
expenseTypeId: UUID_SCHEMA,
|
||||||
|
description: z.string().trim().min(1).max(500),
|
||||||
|
defaultAmount: MONEY_STRING_SCHEMA.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.defaultAmount != null && Number(value.defaultAmount) <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "must_be_positive",
|
||||||
|
path: ["defaultAmount"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const LIST_QUERY_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
enabled: z.enum(["true", "false", "1", "0"]).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const CREATE_BODY_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
description: z.string().trim().max(500).nullable().optional(),
|
||||||
|
items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).max(10000).optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const UPDATE_BODY_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
description: z.string().trim().max(500).nullable().optional(),
|
||||||
|
items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).max(10000).optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTemplateId(value) {
|
||||||
|
const parsed = UUID_SCHEMA.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError({ id: "invalid_uuid" });
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListQuery(query) {
|
||||||
|
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError(parsed.error.flatten());
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledRaw = parsed.data.enabled;
|
||||||
|
const enabled =
|
||||||
|
enabledRaw == null
|
||||||
|
? null
|
||||||
|
: enabledRaw === "true" || enabledRaw === "1";
|
||||||
|
|
||||||
|
return { enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError(parsed.error.flatten());
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateItems(items) {
|
||||||
|
return (items ?? []).map((item) => ({
|
||||||
|
expenseTypeId: item.expenseTypeId,
|
||||||
|
description: item.description.trim(),
|
||||||
|
...(item.defaultAmount == null ? {} : { defaultAmount: item.defaultAmount.trim() }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExpenseTypesEnabled(tx, items) {
|
||||||
|
if (!items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenseTypeIds = Array.from(new Set(items.map((item) => item.expenseTypeId)));
|
||||||
|
const expenseTypes = await tx.expenseType.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: expenseTypeIds },
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledIds = new Set(expenseTypes.map((item) => item.id));
|
||||||
|
const invalidItems = items
|
||||||
|
.map((item, index) => ({ index, expenseTypeId: item.expenseTypeId }))
|
||||||
|
.filter((item) => !enabledIds.has(item.expenseTypeId));
|
||||||
|
|
||||||
|
if (invalidItems.length) {
|
||||||
|
throw buildValidationError(
|
||||||
|
{
|
||||||
|
items: invalidItems.map((item) => ({
|
||||||
|
index: item.index,
|
||||||
|
expenseTypeId: item.expenseTypeId,
|
||||||
|
reason: "expense_type_not_found_or_disabled",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTemplate(record) {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
description: record.description ?? null,
|
||||||
|
items: Array.isArray(record.items) ? record.items : [],
|
||||||
|
sortOrder: record.sort_order,
|
||||||
|
enabled: record.enabled,
|
||||||
|
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshot(record) {
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
description: record.description ?? null,
|
||||||
|
items: Array.isArray(record.items) ? record.items : [],
|
||||||
|
sortOrder: record.sort_order,
|
||||||
|
enabled: record.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reimbursementTemplatesRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursement-templates",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseListQuery(request.query);
|
||||||
|
const items = await fastify.prisma.reimbursementTemplate.findMany({
|
||||||
|
where: {
|
||||||
|
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
||||||
|
},
|
||||||
|
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }, { name: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
items: true,
|
||||||
|
sort_order: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
enabled: query.enabled,
|
||||||
|
items: items.map(mapTemplate),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
"/reimbursement-templates",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
|
||||||
|
const items = normalizeTemplateItems(body.items);
|
||||||
|
await ensureExpenseTypesEnabled(fastify.prisma, items);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const record = await tx.reimbursementTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: body.name.trim(),
|
||||||
|
description: body.description?.trim?.() || null,
|
||||||
|
items,
|
||||||
|
sort_order: body.sortOrder ?? 0,
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
created_at: now,
|
||||||
|
created_by: request.employee.id,
|
||||||
|
updated_at: now,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
items: true,
|
||||||
|
sort_order: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "REIMBURSEMENT_TEMPLATE_CREATED",
|
||||||
|
targetType: "reimbursement_template",
|
||||||
|
targetId: record.id,
|
||||||
|
extra: {
|
||||||
|
after: normalizeSnapshot(record),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, mapTemplate(created));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
"/reimbursement-templates/:id",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const templateId = parseTemplateId(request.params?.id);
|
||||||
|
const body = parseBody(UPDATE_BODY_SCHEMA, request.body);
|
||||||
|
|
||||||
|
if (!Object.keys(body).length) {
|
||||||
|
throw buildValidationError({ body: "empty" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.reimbursementTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
items: true,
|
||||||
|
sort_order: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems =
|
||||||
|
body.items == null
|
||||||
|
? Array.isArray(current.items)
|
||||||
|
? current.items
|
||||||
|
: []
|
||||||
|
: normalizeTemplateItems(body.items);
|
||||||
|
|
||||||
|
await ensureExpenseTypesEnabled(tx, nextItems);
|
||||||
|
|
||||||
|
const record = await tx.reimbursementTemplate.update({
|
||||||
|
where: { id: templateId },
|
||||||
|
data: {
|
||||||
|
...(body.name === undefined ? {} : { name: body.name.trim() }),
|
||||||
|
...(body.description === undefined ? {} : { description: body.description?.trim?.() || null }),
|
||||||
|
...(body.items === undefined ? {} : { items: nextItems }),
|
||||||
|
...(body.sortOrder === undefined ? {} : { sort_order: body.sortOrder }),
|
||||||
|
...(body.enabled === undefined ? {} : { enabled: body.enabled }),
|
||||||
|
updated_at: now,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
items: true,
|
||||||
|
sort_order: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "REIMBURSEMENT_TEMPLATE_UPDATED",
|
||||||
|
targetType: "reimbursement_template",
|
||||||
|
targetId: record.id,
|
||||||
|
extra: {
|
||||||
|
before: normalizeSnapshot(current),
|
||||||
|
after: normalizeSnapshot(record),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, mapTemplate(updated));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.delete(
|
||||||
|
"/reimbursement-templates/:id",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const templateId = parseTemplateId(request.params?.id);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const deleted = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.reimbursementTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
items: true,
|
||||||
|
sort_order: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.enabled) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await tx.reimbursementTemplate.update({
|
||||||
|
where: { id: templateId },
|
||||||
|
data: {
|
||||||
|
enabled: false,
|
||||||
|
updated_at: now,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
items: true,
|
||||||
|
sort_order: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "REIMBURSEMENT_TEMPLATE_DELETED",
|
||||||
|
targetType: "reimbursement_template",
|
||||||
|
targetId: record.id,
|
||||||
|
extra: {
|
||||||
|
before: normalizeSnapshot(current),
|
||||||
|
after: normalizeSnapshot(record),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, mapTemplate(deleted));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = reimbursementTemplatesRoutes;
|
||||||
+546
-53
@@ -55,6 +55,30 @@ const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
|||||||
description: z.string().trim().max(500).nullable().optional(),
|
description: z.string().trim().max(500).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
ownerEmployeeId: z.string().uuid().optional(),
|
||||||
|
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
templateId: z.string().uuid().optional(),
|
||||||
|
linkedTravelRequestId: z.string().uuid().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.templateId && value.linkedTravelRequestId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "mutually_exclusive",
|
||||||
|
path: ["templateId"],
|
||||||
|
});
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "mutually_exclusive",
|
||||||
|
path: ["linkedTravelRequestId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||||
expenseTypeId: z.string().uuid(),
|
expenseTypeId: z.string().uuid(),
|
||||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
@@ -74,6 +98,11 @@ const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend
|
|||||||
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
|
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
|
||||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||||
});
|
});
|
||||||
|
const LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
|
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
|
||||||
const ROLE_LABELS = {
|
const ROLE_LABELS = {
|
||||||
@@ -86,6 +115,28 @@ const ROLE_LABELS = {
|
|||||||
hr: "HR",
|
hr: "HR",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TRAVEL_SEGMENT_ROLE_LABELS = {
|
||||||
|
outbound: "去程",
|
||||||
|
return: "返程",
|
||||||
|
connection: "中转",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRAVEL_TRANSPORT_LABELS = {
|
||||||
|
flight: "航班",
|
||||||
|
train: "火车",
|
||||||
|
bus: "大巴",
|
||||||
|
car: "汽车",
|
||||||
|
other: "其他",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
||||||
|
flight: "travel_airfare",
|
||||||
|
train: "travel_train",
|
||||||
|
bus: "travel_bus",
|
||||||
|
car: "travel_taxi",
|
||||||
|
other: "transport",
|
||||||
|
};
|
||||||
|
|
||||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||||
if (canViewAll) {
|
if (canViewAll) {
|
||||||
@@ -254,6 +305,36 @@ function parseStatsQuery(query) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCreateBody(body) {
|
||||||
|
const parsed = REIMBURSEMENT_CREATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLinkableTravelRequestsQuery(query) {
|
||||||
|
const parsed = LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerEmployeeId: parsed.data.ownerEmployeeId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseTrendQuery(query) {
|
function parseTrendQuery(query) {
|
||||||
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
|
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
|
||||||
const base = parseStatsQuery(parsed);
|
const base = parseStatsQuery(parsed);
|
||||||
@@ -554,6 +635,255 @@ function toDateOnlyString(dateValue) {
|
|||||||
return new Date(dateValue).toISOString().slice(0, 10);
|
return new Date(dateValue).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapLinkedTravelRequestSummary(travelRequest) {
|
||||||
|
if (!travelRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: travelRequest.id,
|
||||||
|
requestNo: travelRequest.request_no ?? null,
|
||||||
|
purpose: travelRequest.purpose ?? null,
|
||||||
|
startDate: toDateOnlyString(travelRequest.start_date),
|
||||||
|
endDate: toDateOnlyString(travelRequest.end_date),
|
||||||
|
estimatedAmount: toMoneyString(travelRequest.estimated_amount ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreatedReimbursementItem(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
expenseTypeId: item.expense_type_id,
|
||||||
|
expenseDate: toDateOnlyString(item.expense_date),
|
||||||
|
amount: toMoneyString(item.amount),
|
||||||
|
description: item.description ?? null,
|
||||||
|
expenseType: item.expense_type
|
||||||
|
? {
|
||||||
|
id: item.expense_type.id,
|
||||||
|
code: item.expense_type.code,
|
||||||
|
name: item.expense_type.name,
|
||||||
|
category: item.expense_type.category,
|
||||||
|
enabled: item.expense_type.enabled,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateSeedItems(rawItems) {
|
||||||
|
if (!Array.isArray(rawItems)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawItems.map((item, index) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.expenseTypeId !== "string" || typeof item.description !== "string") {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expenseTypeId: item.expenseTypeId,
|
||||||
|
description: item.description,
|
||||||
|
defaultAmount: typeof item.defaultAmount === "string" ? item.defaultAmount : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEnabledExpenseTypesByIds(tx, expenseTypeIds) {
|
||||||
|
if (!expenseTypeIds.length) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await tx.expenseType.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: expenseTypeIds },
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map(items.map((item) => [item.id, item]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTravelExpenseTypeMap(tx) {
|
||||||
|
const codes = Array.from(new Set(Object.values(TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES)));
|
||||||
|
const items = await tx.expenseType.findMany({
|
||||||
|
where: {
|
||||||
|
code: { in: codes },
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map(items.map((item) => [item.code, item]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTravelSegmentDescription(segment) {
|
||||||
|
const segmentRoleLabel = TRAVEL_SEGMENT_ROLE_LABELS[segment.segment_role] ?? segment.segment_role;
|
||||||
|
const transportLabel = TRAVEL_TRANSPORT_LABELS[segment.transport_type] ?? segment.transport_type;
|
||||||
|
return `${segmentRoleLabel} ${transportLabel} ${segment.from_city}→${segment.to_city}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId) {
|
||||||
|
const isProxyCreate = ownerEmployeeId !== request.employee.id;
|
||||||
|
|
||||||
|
if (!isProxyCreate) {
|
||||||
|
return {
|
||||||
|
ownerEmployeeId,
|
||||||
|
ownerEmployee: {
|
||||||
|
id: request.employee.id,
|
||||||
|
department_id: request.employee.department_id ?? null,
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
departmentId: request.employee.department_id ?? null,
|
||||||
|
delegationGrantId: null,
|
||||||
|
isProxyCreate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdministration = request.employee.roleCodes?.includes("administration");
|
||||||
|
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
||||||
|
if (!isAdministration || !canProxySubmit) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerEmployee = await tx.employee.findUnique({
|
||||||
|
where: { id: ownerEmployeeId },
|
||||||
|
select: { id: true, status: true, department_id: true },
|
||||||
|
});
|
||||||
|
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { ownerEmployeeId: "invalid_owner" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegation = await findActiveDelegationForDelegate(tx, {
|
||||||
|
principalEmployeeId: ownerEmployeeId,
|
||||||
|
delegateEmployeeId: request.employee.id,
|
||||||
|
allowedScopes: ["reimbursement_submit", "all"],
|
||||||
|
now: new Date(),
|
||||||
|
});
|
||||||
|
if (!delegation) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
details: { delegation: "missing_active_grant" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerEmployeeId,
|
||||||
|
ownerEmployee,
|
||||||
|
departmentId: ownerEmployee.department_id,
|
||||||
|
delegationGrantId: delegation.id,
|
||||||
|
isProxyCreate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLinkableTravelRequests(tx, ownerEmployeeId) {
|
||||||
|
return tx.travelRequest.findMany({
|
||||||
|
where: {
|
||||||
|
applicant_employee_id: ownerEmployeeId,
|
||||||
|
status: "approved",
|
||||||
|
linked_reimbursements: {
|
||||||
|
none: {},
|
||||||
|
},
|
||||||
|
travel_orders: {
|
||||||
|
none: {
|
||||||
|
linked_reimbursement_id: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_no: true,
|
||||||
|
purpose: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
estimated_amount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTravelRequestForLinking(tx, linkedTravelRequestId) {
|
||||||
|
return tx.travelRequest.findUnique({
|
||||||
|
where: { id: linkedTravelRequestId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_no: true,
|
||||||
|
purpose: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
estimated_amount: true,
|
||||||
|
applicant_employee_id: true,
|
||||||
|
status: true,
|
||||||
|
segments: {
|
||||||
|
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
segment_role: true,
|
||||||
|
transport_type: true,
|
||||||
|
from_city: true,
|
||||||
|
to_city: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
linked_reimbursements: {
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
travel_orders: {
|
||||||
|
where: {
|
||||||
|
linked_reimbursement_id: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
linked_reimbursement_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function ensureNonNegativeAmount(totalAmount) {
|
function ensureNonNegativeAmount(totalAmount) {
|
||||||
const asNumber = Number(totalAmount);
|
const asNumber = Number(totalAmount);
|
||||||
if (!Number.isFinite(asNumber)) {
|
if (!Number.isFinite(asNumber)) {
|
||||||
@@ -895,12 +1225,6 @@ async function finalizeReimbursementSubmitSideEffects({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reimbursementRoutes(fastify) {
|
async function reimbursementRoutes(fastify) {
|
||||||
const createBodySchema = z.object({
|
|
||||||
ownerEmployeeId: z.string().uuid().optional(),
|
|
||||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
||||||
description: z.string().max(500).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/reimbursements",
|
"/reimbursements",
|
||||||
{
|
{
|
||||||
@@ -915,76 +1239,148 @@ async function reimbursementRoutes(fastify) {
|
|||||||
domain: "expense",
|
domain: "expense",
|
||||||
departmentId: request.employee.department_id ?? null,
|
departmentId: request.employee.department_id ?? null,
|
||||||
});
|
});
|
||||||
const parsed = createBodySchema.safeParse(request.body ?? {});
|
const body = parseCreateBody(request.body);
|
||||||
if (!parsed.success) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: parsed.error.flatten(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const reimbursementDate =
|
const reimbursementDate =
|
||||||
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
|
body.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
|
||||||
|
|
||||||
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
|
const ownerEmployeeId = body.ownerEmployeeId ?? request.employee.id;
|
||||||
const isProxyCreate = ownerEmployeeId !== request.employee.id;
|
|
||||||
|
|
||||||
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
|
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
|
||||||
let departmentId = request.employee.department_id;
|
const ownerContext = await resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId);
|
||||||
let delegationGrantId = null;
|
|
||||||
|
|
||||||
if (isProxyCreate) {
|
let linkedTravelRequestRecord = null;
|
||||||
const isAdministration = request.employee.roleCodes?.includes("administration");
|
let generatedItemSeeds = [];
|
||||||
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
let resolvedDescription = body.description ?? null;
|
||||||
if (!isAdministration || !canProxySubmit) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "PERM_001",
|
|
||||||
message: "无权限",
|
|
||||||
statusCode: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerEmployee = await tx.employee.findUnique({
|
if (body.templateId) {
|
||||||
where: { id: ownerEmployeeId },
|
const template = await tx.reimbursementTemplate.findFirst({
|
||||||
select: { id: true, status: true, department_id: true },
|
where: {
|
||||||
|
id: body.templateId,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
|
||||||
|
if (!template) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
message: "参数校验失败",
|
message: "参数校验失败",
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
details: { ownerEmployeeId: "invalid_owner" },
|
details: { templateId: "invalid_template" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const delegation = await findActiveDelegationForDelegate(tx, {
|
const templateItems = normalizeTemplateSeedItems(template.items);
|
||||||
principalEmployeeId: ownerEmployeeId,
|
const expenseTypeMap = await resolveEnabledExpenseTypesByIds(
|
||||||
delegateEmployeeId: request.employee.id,
|
tx,
|
||||||
allowedScopes: ["reimbursement_submit", "all"],
|
Array.from(new Set(templateItems.map((item) => item.expenseTypeId))),
|
||||||
now: new Date(),
|
);
|
||||||
});
|
const invalidItem = templateItems.find((item) => !expenseTypeMap.has(item.expenseTypeId));
|
||||||
if (!delegation) {
|
if (invalidItem) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "PERM_001",
|
code: "VALID_001",
|
||||||
message: "无权限",
|
message: "参数校验失败",
|
||||||
statusCode: 403,
|
statusCode: 409,
|
||||||
details: { delegation: "missing_active_grant" },
|
details: { templateId: "contains_disabled_expense_type" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delegationGrantId = delegation.id;
|
generatedItemSeeds = templateItems.map((item) => ({
|
||||||
departmentId = ownerEmployee.department_id;
|
expense_type_id: item.expenseTypeId,
|
||||||
|
expense_date: parseDateOnlyString(reimbursementDate),
|
||||||
|
amount: item.defaultAmount ?? "0.00",
|
||||||
|
description: item.description,
|
||||||
|
created_by: request.employee.id,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.linkedTravelRequestId) {
|
||||||
|
linkedTravelRequestRecord = await loadTravelRequestForLinking(tx, body.linkedTravelRequestId);
|
||||||
|
if (!linkedTravelRequestRecord) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { linkedTravelRequestId: "not_found" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedTravelRequestRecord.status !== "approved") {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { linkedTravelRequestId: "not_approved" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedTravelRequestRecord.applicant_employee_id !== ownerContext.ownerEmployeeId) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { linkedTravelRequestId: "owner_mismatch" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
linkedTravelRequestRecord.linked_reimbursements?.length ||
|
||||||
|
linkedTravelRequestRecord.travel_orders?.length
|
||||||
|
) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { linkedTravelRequestId: "already_linked" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.description == null) {
|
||||||
|
resolvedDescription = `差旅报销 - ${linkedTravelRequestRecord.request_no ?? linkedTravelRequestRecord.id} ${linkedTravelRequestRecord.purpose}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenseTypeMap = await resolveTravelExpenseTypeMap(tx);
|
||||||
|
generatedItemSeeds = linkedTravelRequestRecord.segments.map((segment) => {
|
||||||
|
const expenseTypeCode = TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES[segment.transport_type];
|
||||||
|
const expenseType = expenseTypeMap.get(expenseTypeCode);
|
||||||
|
if (!expenseType) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: {
|
||||||
|
linkedTravelRequestId: "missing_expense_type_mapping",
|
||||||
|
transportType: segment.transport_type,
|
||||||
|
expenseTypeCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expense_type_id: expenseType.id,
|
||||||
|
expense_date: parseDateOnlyString(reimbursementDate),
|
||||||
|
amount: "0.00",
|
||||||
|
description: buildTravelSegmentDescription(segment),
|
||||||
|
created_by: request.employee.id,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await tx.reimbursementForm.create({
|
const created = await tx.reimbursementForm.create({
|
||||||
data: {
|
data: {
|
||||||
submitter_id: request.employee.id,
|
submitter_id: request.employee.id,
|
||||||
owner_id: ownerEmployeeId,
|
owner_id: ownerEmployeeId,
|
||||||
department_id: departmentId,
|
department_id: ownerContext.departmentId,
|
||||||
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
||||||
description: parsed.data.description ?? null,
|
description: resolvedDescription,
|
||||||
|
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -997,10 +1393,54 @@ async function reimbursementRoutes(fastify) {
|
|||||||
form_number: true,
|
form_number: true,
|
||||||
owner_id: true,
|
owner_id: true,
|
||||||
submitter_id: true,
|
submitter_id: true,
|
||||||
|
linked_travel_request_id: true,
|
||||||
|
travel_request: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_no: true,
|
||||||
|
purpose: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
estimated_amount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isProxyCreate) {
|
if (generatedItemSeeds.length) {
|
||||||
|
await tx.reimbursementItem.createMany({
|
||||||
|
data: generatedItemSeeds.map((item) => ({
|
||||||
|
reimbursement_id: created.id,
|
||||||
|
...item,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
created.total_amount = await recomputeTotalAmount(tx, created.id, request.employee.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdItems = generatedItemSeeds.length
|
||||||
|
? await tx.reimbursementItem.findMany({
|
||||||
|
where: { reimbursement_id: created.id },
|
||||||
|
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
expense_type_id: true,
|
||||||
|
expense_date: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
expense_type: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (ownerContext.isProxyCreate) {
|
||||||
await tx.auditLog.create({
|
await tx.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
@@ -1010,13 +1450,18 @@ async function reimbursementRoutes(fastify) {
|
|||||||
extra: {
|
extra: {
|
||||||
ownerEmployeeId,
|
ownerEmployeeId,
|
||||||
submitterEmployeeId: request.employee.id,
|
submitterEmployeeId: request.employee.id,
|
||||||
delegationGrantId,
|
delegationGrantId: ownerContext.delegationGrantId,
|
||||||
|
...(body.templateId ? { templateId: body.templateId } : {}),
|
||||||
|
...(linkedTravelRequestRecord ? { linkedTravelRequestId: linkedTravelRequestRecord.id } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return {
|
||||||
|
...created,
|
||||||
|
items: createdItems,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
@@ -1030,6 +1475,9 @@ async function reimbursementRoutes(fastify) {
|
|||||||
projectId: reimbursement.project_id ?? null,
|
projectId: reimbursement.project_id ?? null,
|
||||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
|
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||||
|
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||||
|
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1372,6 +1820,38 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursements/linkable-travel-requests",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Reimbursements"], summary: "List approved travel requests that can be linked to reimbursement" },
|
||||||
|
preHandler: fastify.requirePermission("expense.create"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseLinkableTravelRequestsQuery(request.query);
|
||||||
|
const ownerEmployeeId = query.ownerEmployeeId ?? request.employee.id;
|
||||||
|
|
||||||
|
const ownerContext = await resolveReimbursementOwnerContext(
|
||||||
|
fastify.prisma,
|
||||||
|
fastify,
|
||||||
|
request,
|
||||||
|
ownerEmployeeId,
|
||||||
|
);
|
||||||
|
const items = await loadLinkableTravelRequests(fastify.prisma, ownerContext.ownerEmployeeId);
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
ownerEmployeeId: ownerContext.ownerEmployeeId,
|
||||||
|
items: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
requestNo: item.request_no ?? null,
|
||||||
|
purpose: item.purpose,
|
||||||
|
startDate: toDateOnlyString(item.start_date),
|
||||||
|
endDate: toDateOnlyString(item.end_date),
|
||||||
|
estimatedAmount: toMoneyString(item.estimated_amount ?? 0),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/reimbursements/:id",
|
"/reimbursements/:id",
|
||||||
{
|
{
|
||||||
@@ -1394,7 +1874,18 @@ async function reimbursementRoutes(fastify) {
|
|||||||
status: true,
|
status: true,
|
||||||
description: true,
|
description: true,
|
||||||
project_id: true,
|
project_id: true,
|
||||||
|
linked_travel_request_id: true,
|
||||||
project: { select: { id: true, project_no: true, name: true } },
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
|
travel_request: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_no: true,
|
||||||
|
purpose: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
estimated_amount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
owner: {
|
owner: {
|
||||||
select: { id: true, employee_no: true, name: true },
|
select: { id: true, employee_no: true, name: true },
|
||||||
},
|
},
|
||||||
@@ -1507,6 +1998,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
description: reimbursement.description,
|
description: reimbursement.description,
|
||||||
projectId: reimbursement.project_id ?? null,
|
projectId: reimbursement.project_id ?? null,
|
||||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||||
|
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||||
|
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||||
owner: reimbursement.owner,
|
owner: reimbursement.owner,
|
||||||
submitter: reimbursement.submitter,
|
submitter: reimbursement.submitter,
|
||||||
items: reimbursement.items.map((item) => ({
|
items: reimbursement.items.map((item) => ({
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const reimbursementTemplatesRoutes = require("../../../src/routes/reimbursementTemplates");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const TEMPLATE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const DISABLED_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
|
|
||||||
|
function cloneTemplate(item) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [],
|
||||||
|
created_at: new Date(item.created_at),
|
||||||
|
updated_at: new Date(item.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplate(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: TEMPLATE_ID,
|
||||||
|
name: "国内差旅报销",
|
||||||
|
description: "机票、火车票与市内交通占位模板",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
expenseTypeId: EXPENSE_TYPE_ID,
|
||||||
|
description: "默认交通费",
|
||||||
|
defaultAmount: "88.80",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 10,
|
||||||
|
enabled: true,
|
||||||
|
created_at: new Date("2026-04-08T12:00:00.000Z"),
|
||||||
|
updated_at: new Date("2026-04-08T12:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp(options = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
const actor = options.actor ?? {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
permissionCodes: ["system.settings"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
templates: (options.templates ?? [buildTemplate()]).map((item) => cloneTemplate(item)),
|
||||||
|
expenseTypes: options.expenseTypes ?? [
|
||||||
|
{
|
||||||
|
id: EXPENSE_TYPE_ID,
|
||||||
|
code: "travel_airfare",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DISABLED_EXPENSE_TYPE_ID,
|
||||||
|
code: "transport",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
auditLogs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPrismaLike(stateRef) {
|
||||||
|
return {
|
||||||
|
reimbursementTemplate: {
|
||||||
|
findMany: vi.fn(async ({ where, orderBy } = {}) => {
|
||||||
|
let items = stateRef.templates.filter((item) => {
|
||||||
|
if (typeof where?.enabled === "boolean" && item.enabled !== where.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(orderBy)) {
|
||||||
|
items = items.sort((left, right) => {
|
||||||
|
if (left.sort_order !== right.sort_order) {
|
||||||
|
return left.sort_order - right.sort_order;
|
||||||
|
}
|
||||||
|
return left.name.localeCompare(right.name, "zh-CN");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => cloneTemplate(item));
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }) => {
|
||||||
|
const found = stateRef.templates.find((item) => item.id === where.id) ?? null;
|
||||||
|
return found ? cloneTemplate(found) : null;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async ({ data }) => {
|
||||||
|
const record = buildTemplate({
|
||||||
|
id: `66666666-6666-4666-8666-${String(stateRef.templates.length + 1).padStart(12, "0")}`,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
items: data.items ?? [],
|
||||||
|
sort_order: data.sort_order ?? 0,
|
||||||
|
enabled: data.enabled ?? true,
|
||||||
|
created_at: data.created_at,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
});
|
||||||
|
stateRef.templates.push(record);
|
||||||
|
return cloneTemplate(record);
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }) => {
|
||||||
|
const index = stateRef.templates.findIndex((item) => item.id === where.id);
|
||||||
|
if (index < 0) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
...stateRef.templates[index],
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
stateRef.templates[index] = next;
|
||||||
|
return cloneTemplate(next);
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(async ({ where }) => {
|
||||||
|
const found = stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null;
|
||||||
|
return found ? cloneTemplate(found) : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
expenseType: {
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
const ids = where?.id?.in ?? [];
|
||||||
|
return stateRef.expenseTypes.filter(
|
||||||
|
(item) => ids.includes(item.id) && item.enabled === where.enabled,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn(async ({ data }) => {
|
||||||
|
stateRef.auditLogs.push(data);
|
||||||
|
return { id: `audit-${stateRef.auditLogs.length}` };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = buildPrismaLike(state);
|
||||||
|
prisma.$transaction = vi.fn(async (callback) => {
|
||||||
|
const txState = {
|
||||||
|
templates: state.templates.map((item) => cloneTemplate(item)),
|
||||||
|
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
|
||||||
|
auditLogs: state.auditLogs.map((item) => ({ ...item })),
|
||||||
|
};
|
||||||
|
const tx = buildPrismaLike(txState);
|
||||||
|
const result = await callback(tx);
|
||||||
|
state.templates = txState.templates;
|
||||||
|
state.expenseTypes = txState.expenseTypes;
|
||||||
|
state.auditLogs = txState.auditLogs;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
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("prisma", prisma);
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
reply.status(error.statusCode || 500).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: {
|
||||||
|
code: error.code || "SYS_001",
|
||||||
|
message: error.message,
|
||||||
|
details: error.details ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(reimbursementTemplatesRoutes, { prefix: "/api" });
|
||||||
|
return { app, state, prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reimbursement template routes", () => {
|
||||||
|
it("allows authenticated users to list templates with enabled filter", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
permissionCodes: [],
|
||||||
|
},
|
||||||
|
templates: [
|
||||||
|
buildTemplate(),
|
||||||
|
buildTemplate({
|
||||||
|
id: "44444444-4444-4444-8444-444444444444",
|
||||||
|
name: "停用模板",
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/reimbursement-templates?enabled=true",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: TEMPLATE_ID,
|
||||||
|
name: "国内差旅报销",
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, updates and soft deletes a template with audit logs", async () => {
|
||||||
|
const { app, state } = await buildApp();
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursement-templates",
|
||||||
|
payload: {
|
||||||
|
name: "办公用品报销",
|
||||||
|
description: "默认办公用品采购占位",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
expenseTypeId: EXPENSE_TYPE_ID,
|
||||||
|
description: "办公耗材",
|
||||||
|
defaultAmount: "123.40",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 5,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const createdId = createResponse.json().data.id;
|
||||||
|
expect(state.auditLogs).toHaveLength(1);
|
||||||
|
expect(state.auditLogs[0].action).toBe("REIMBURSEMENT_TEMPLATE_CREATED");
|
||||||
|
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/reimbursement-templates/${createdId}`,
|
||||||
|
payload: {
|
||||||
|
name: "办公用品报销模板",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
expect(updateResponse.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: createdId,
|
||||||
|
name: "办公用品报销模板",
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/reimbursement-templates/${createdId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.statusCode).toBe(200);
|
||||||
|
expect(deleteResponse.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: createdId,
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.auditLogs.map((item) => item.action)).toEqual([
|
||||||
|
"REIMBURSEMENT_TEMPLATE_CREATED",
|
||||||
|
"REIMBURSEMENT_TEMPLATE_UPDATED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid item schema and disabled expense types", async () => {
|
||||||
|
const { app } = await buildApp();
|
||||||
|
|
||||||
|
const invalidSchemaResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursement-templates",
|
||||||
|
payload: {
|
||||||
|
name: "坏模板",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
expenseTypeId: EXPENSE_TYPE_ID,
|
||||||
|
description: "无效金额",
|
||||||
|
defaultAmount: 18.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidSchemaResponse.statusCode).toBe(400);
|
||||||
|
|
||||||
|
const invalidExpenseTypeResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursement-templates",
|
||||||
|
payload: {
|
||||||
|
name: "禁用费用类型模板",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
expenseTypeId: DISABLED_EXPENSE_TYPE_ID,
|
||||||
|
description: "旧费用类型",
|
||||||
|
defaultAmount: "9.90",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidExpenseTypeResponse.statusCode).toBe(409);
|
||||||
|
expect(invalidExpenseTypeResponse.json().error).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "VALID_001",
|
||||||
|
details: expect.objectContaining({
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
expenseTypeId: DISABLED_EXPENSE_TYPE_ID,
|
||||||
|
reason: "expense_type_not_found_or_disabled",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks management actions without system.settings", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
permissionCodes: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursement-templates",
|
||||||
|
payload: {
|
||||||
|
name: "无权限模板",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
const cutover = require("../../../src/services/cutover");
|
||||||
|
const delegationUtils = require("../../../src/utils/delegation");
|
||||||
|
|
||||||
|
const ensureCutoverAllowedMock = vi.fn();
|
||||||
|
const findActiveDelegationForDelegateMock = vi.fn();
|
||||||
|
const listActiveDelegationPrincipalsMock = vi.fn();
|
||||||
|
|
||||||
|
const originalEnsureCutoverAllowed = cutover.ensureCutoverAllowed;
|
||||||
|
const originalFindActiveDelegationForDelegate = delegationUtils.findActiveDelegationForDelegate;
|
||||||
|
const originalListActiveDelegationPrincipals = delegationUtils.listActiveDelegationPrincipals;
|
||||||
|
|
||||||
|
cutover.ensureCutoverAllowed = ensureCutoverAllowedMock;
|
||||||
|
delegationUtils.findActiveDelegationForDelegate = findActiveDelegationForDelegateMock;
|
||||||
|
delegationUtils.listActiveDelegationPrincipals = listActiveDelegationPrincipalsMock;
|
||||||
|
|
||||||
|
delete require.cache[require.resolve("../../../src/routes/reimbursements")];
|
||||||
|
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||||
|
|
||||||
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const OWNER_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
const DEPARTMENT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||||
|
const TEMPLATE_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||||
|
const TRAVEL_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||||
|
const ALT_TRAVEL_ID = "ffffffff-ffff-4fff-8fff-ffffffffffff";
|
||||||
|
const AIRFARE_TYPE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const TRAIN_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const BUS_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const TAXI_TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const TRANSPORT_TYPE_ID = "55555555-5555-4555-8555-555555555555";
|
||||||
|
|
||||||
|
function cloneDate(value) {
|
||||||
|
return value ? new Date(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneTravelRequest(item) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
start_date: cloneDate(item.start_date),
|
||||||
|
end_date: cloneDate(item.end_date),
|
||||||
|
created_at: cloneDate(item.created_at),
|
||||||
|
segments: (item.segments ?? []).map((segment) => ({ ...segment, created_at: cloneDate(segment.created_at) })),
|
||||||
|
linked_reimbursements: (item.linked_reimbursements ?? []).map((record) => ({ ...record })),
|
||||||
|
travel_orders: (item.travel_orders ?? []).map((record) => ({ ...record })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplate(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: TEMPLATE_ID,
|
||||||
|
name: "国内差旅模板",
|
||||||
|
enabled: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
expenseTypeId: AIRFARE_TYPE_ID,
|
||||||
|
description: "机票占位",
|
||||||
|
defaultAmount: "888.80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expenseTypeId: BUS_TYPE_ID,
|
||||||
|
description: "市内交通占位",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTravelRequest(overrides = {}) {
|
||||||
|
return cloneTravelRequest({
|
||||||
|
id: TRAVEL_ID,
|
||||||
|
request_no: "TR-20260408-001",
|
||||||
|
purpose: "上海客户拜访",
|
||||||
|
applicant_employee_id: ACTOR_ID,
|
||||||
|
status: "approved",
|
||||||
|
start_date: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
end_date: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
estimated_amount: "3600.00",
|
||||||
|
created_at: new Date("2026-04-08T12:00:00.000Z"),
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
id: "segment-1",
|
||||||
|
sort_order: 1,
|
||||||
|
segment_role: "outbound",
|
||||||
|
transport_type: "flight",
|
||||||
|
from_city: "上海",
|
||||||
|
to_city: "深圳",
|
||||||
|
created_at: new Date("2026-04-08T12:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "segment-2",
|
||||||
|
sort_order: 2,
|
||||||
|
segment_role: "return",
|
||||||
|
transport_type: "bus",
|
||||||
|
from_city: "深圳",
|
||||||
|
to_city: "上海",
|
||||||
|
created_at: new Date("2026-04-08T12:01:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
linked_reimbursements: [],
|
||||||
|
travel_orders: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp(options = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
const actor = options.actor ?? {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
roleCodes: [],
|
||||||
|
permissionCodes: ["expense.create"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
reimbursements: [],
|
||||||
|
reimbursementItems: [],
|
||||||
|
templates: (options.templates ?? [buildTemplate()]).map((item) => ({
|
||||||
|
...item,
|
||||||
|
items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [],
|
||||||
|
})),
|
||||||
|
employees: options.employees ?? [
|
||||||
|
{ id: ACTOR_ID, department_id: DEPARTMENT_ID, status: "active" },
|
||||||
|
{ id: OWNER_ID, department_id: DEPARTMENT_ID, status: "active" },
|
||||||
|
],
|
||||||
|
expenseTypes: options.expenseTypes ?? [
|
||||||
|
{ id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true },
|
||||||
|
{ id: TRAIN_TYPE_ID, code: "travel_train", name: "火车票", category: "travel", enabled: true },
|
||||||
|
{ id: BUS_TYPE_ID, code: "travel_bus", name: "市内交通", category: "travel", enabled: true },
|
||||||
|
{ id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true },
|
||||||
|
{ id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true },
|
||||||
|
],
|
||||||
|
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
|
||||||
|
auditLogs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildReimbursementSelect(record, select) {
|
||||||
|
if (!select) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(select.id ? { id: record.id } : {}),
|
||||||
|
...(select.status ? { status: record.status } : {}),
|
||||||
|
...(select.reimbursement_date ? { reimbursement_date: record.reimbursement_date } : {}),
|
||||||
|
...(select.description ? { description: record.description } : {}),
|
||||||
|
...(select.total_amount ? { total_amount: record.total_amount } : {}),
|
||||||
|
...(select.form_number ? { form_number: record.form_number } : {}),
|
||||||
|
...(select.owner_id ? { owner_id: record.owner_id } : {}),
|
||||||
|
...(select.submitter_id ? { submitter_id: record.submitter_id } : {}),
|
||||||
|
...(select.linked_travel_request_id ? { linked_travel_request_id: record.linked_travel_request_id ?? null } : {}),
|
||||||
|
...(select.travel_request
|
||||||
|
? {
|
||||||
|
travel_request: record.linked_travel_request_id
|
||||||
|
? (() => {
|
||||||
|
const travelRequest = state.travelRequests.find((item) => item.id === record.linked_travel_request_id);
|
||||||
|
return travelRequest
|
||||||
|
? {
|
||||||
|
id: travelRequest.id,
|
||||||
|
request_no: travelRequest.request_no,
|
||||||
|
purpose: travelRequest.purpose,
|
||||||
|
start_date: travelRequest.start_date,
|
||||||
|
end_date: travelRequest.end_date,
|
||||||
|
estimated_amount: travelRequest.estimated_amount,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})()
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpenseTypeMap() {
|
||||||
|
return new Map(state.expenseTypes.map((item) => [item.id, item]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrismaLike(stateRef) {
|
||||||
|
return {
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn(async ({ where }) => stateRef.employees.find((item) => item.id === where.id) ?? null),
|
||||||
|
},
|
||||||
|
reimbursementTemplate: {
|
||||||
|
findFirst: vi.fn(async ({ where }) => {
|
||||||
|
return stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
expenseType: {
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
const ids = where?.id?.in ?? null;
|
||||||
|
const codes = where?.code?.in ?? null;
|
||||||
|
return stateRef.expenseTypes.filter((item) => {
|
||||||
|
if (where?.enabled === true && !item.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ids && !ids.includes(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (codes && !codes.includes(item.code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
travelRequest: {
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
return stateRef.travelRequests
|
||||||
|
.filter((item) => {
|
||||||
|
if (where?.applicant_employee_id && item.applicant_employee_id !== where.applicant_employee_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.status && item.status !== where.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.linked_reimbursements?.none && item.linked_reimbursements.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.travel_orders?.none) {
|
||||||
|
return item.travel_orders.every((order) => order.linked_reimbursement_id == null);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
request_no: item.request_no,
|
||||||
|
purpose: item.purpose,
|
||||||
|
start_date: item.start_date,
|
||||||
|
end_date: item.end_date,
|
||||||
|
estimated_amount: item.estimated_amount,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }) => {
|
||||||
|
const found = stateRef.travelRequests.find((item) => item.id === where.id) ?? null;
|
||||||
|
return found ? cloneTravelRequest(found) : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
reimbursementForm: {
|
||||||
|
create: vi.fn(async ({ data, select }) => {
|
||||||
|
const record = {
|
||||||
|
id: `reimbursement-${stateRef.reimbursements.length + 1}`,
|
||||||
|
form_number: null,
|
||||||
|
submitter_id: data.submitter_id,
|
||||||
|
owner_id: data.owner_id,
|
||||||
|
department_id: data.department_id,
|
||||||
|
reimbursement_date: cloneDate(data.reimbursement_date),
|
||||||
|
total_amount: "0.00",
|
||||||
|
status: "draft",
|
||||||
|
description: data.description ?? null,
|
||||||
|
linked_travel_request_id: data.linked_travel_request_id ?? null,
|
||||||
|
};
|
||||||
|
stateRef.reimbursements.push(record);
|
||||||
|
return buildReimbursementSelect(record, select);
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }) => {
|
||||||
|
const target = stateRef.reimbursements.find((item) => item.id === where.id);
|
||||||
|
if (!target) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
Object.assign(target, data);
|
||||||
|
return { ...target };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
reimbursementItem: {
|
||||||
|
createMany: vi.fn(async ({ data }) => {
|
||||||
|
const baseLength = stateRef.reimbursementItems.length;
|
||||||
|
data.forEach((item, index) => {
|
||||||
|
stateRef.reimbursementItems.push({
|
||||||
|
id: `item-${baseLength + index + 1}`,
|
||||||
|
reimbursement_id: item.reimbursement_id,
|
||||||
|
expense_type_id: item.expense_type_id,
|
||||||
|
expense_date: cloneDate(item.expense_date),
|
||||||
|
amount: item.amount,
|
||||||
|
description: item.description,
|
||||||
|
created_at: new Date(`2026-04-08T12:00:${String(baseLength + index).padStart(2, "0")}.000Z`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { count: data.length };
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
const expenseTypeMap = buildExpenseTypeMap();
|
||||||
|
return stateRef.reimbursementItems
|
||||||
|
.filter((item) => item.reimbursement_id === where.reimbursement_id)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
expense_type_id: item.expense_type_id,
|
||||||
|
expense_date: item.expense_date,
|
||||||
|
amount: item.amount,
|
||||||
|
description: item.description,
|
||||||
|
expense_type: expenseTypeMap.get(item.expense_type_id) ?? null,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
aggregate: vi.fn(async ({ where }) => {
|
||||||
|
const total = stateRef.reimbursementItems
|
||||||
|
.filter((item) => item.reimbursement_id === where.reimbursement_id)
|
||||||
|
.reduce((sum, item) => sum + Number(item.amount ?? 0), 0);
|
||||||
|
return { _sum: { amount: total.toFixed(2) } };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn(async ({ data }) => {
|
||||||
|
stateRef.auditLogs.push(data);
|
||||||
|
return { id: `audit-${stateRef.auditLogs.length}` };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = buildPrismaLike(state);
|
||||||
|
prisma.$transaction = vi.fn(async (callback) => {
|
||||||
|
const txState = {
|
||||||
|
reimbursements: state.reimbursements.map((item) => ({ ...item, reimbursement_date: cloneDate(item.reimbursement_date) })),
|
||||||
|
reimbursementItems: state.reimbursementItems.map((item) => ({ ...item, expense_date: cloneDate(item.expense_date), created_at: cloneDate(item.created_at) })),
|
||||||
|
templates: state.templates.map((item) => ({ ...item, items: item.items.map((entry) => ({ ...entry })) })),
|
||||||
|
employees: state.employees.map((item) => ({ ...item })),
|
||||||
|
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
|
||||||
|
travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)),
|
||||||
|
auditLogs: state.auditLogs.map((item) => ({ ...item })),
|
||||||
|
};
|
||||||
|
const tx = buildPrismaLike(txState);
|
||||||
|
const result = await callback(tx);
|
||||||
|
state.reimbursements = txState.reimbursements;
|
||||||
|
state.reimbursementItems = txState.reimbursementItems;
|
||||||
|
state.templates = txState.templates;
|
||||||
|
state.employees = txState.employees;
|
||||||
|
state.expenseTypes = txState.expenseTypes;
|
||||||
|
state.travelRequests = txState.travelRequests;
|
||||||
|
state.auditLogs = txState.auditLogs;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("config", {
|
||||||
|
appTimezone: "Asia/Shanghai",
|
||||||
|
budgetControlMode: "warn",
|
||||||
|
imNotifierEnabled: false,
|
||||||
|
emailEnabled: false,
|
||||||
|
});
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
request.employee = actor;
|
||||||
|
});
|
||||||
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||||
|
const permissionCodes = employee?.permissionCodes ?? [];
|
||||||
|
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||||
|
});
|
||||||
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||||
|
request.employee = actor;
|
||||||
|
if (!app.hasPermission(actor, permissionCode)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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(reimbursementRoutes, { prefix: "/api" });
|
||||||
|
return { app, state, prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reimbursement create enhancements", () => {
|
||||||
|
afterAll(() => {
|
||||||
|
cutover.ensureCutoverAllowed = originalEnsureCutoverAllowed;
|
||||||
|
delegationUtils.findActiveDelegationForDelegate = originalFindActiveDelegationForDelegate;
|
||||||
|
delegationUtils.listActiveDelegationPrincipals = originalListActiveDelegationPrincipals;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
ensureCutoverAllowedMock.mockResolvedValue(undefined);
|
||||||
|
findActiveDelegationForDelegateMock.mockResolvedValue(null);
|
||||||
|
listActiveDelegationPrincipalsMock.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps blank reimbursement creation working", async () => {
|
||||||
|
const { app, state } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
description: "空白报销",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "空白报销",
|
||||||
|
linkedTravelRequestId: null,
|
||||||
|
items: [],
|
||||||
|
totalAmount: "0.00",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.reimbursementItems).toHaveLength(0);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates reimbursement items from template", async () => {
|
||||||
|
const { app, state } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
templateId: TEMPLATE_ID,
|
||||||
|
reimbursementDate: "2026-04-09",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
totalAmount: "888.80",
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
expenseTypeId: AIRFARE_TYPE_ID,
|
||||||
|
amount: "888.80",
|
||||||
|
expenseDate: "2026-04-09",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
expenseTypeId: BUS_TYPE_ID,
|
||||||
|
amount: "0.00",
|
||||||
|
expenseDate: "2026-04-09",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.reimbursementItems).toHaveLength(2);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects templateId and linkedTravelRequestId when both are provided", async () => {
|
||||||
|
const { app } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
templateId: TEMPLATE_ID,
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates reimbursement linked to approved travel request with generated items", async () => {
|
||||||
|
const { app, state } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
reimbursementDate: "2026-04-11",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "差旅报销 - TR-20260408-001 上海客户拜访",
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
linkedTravelRequest: expect.objectContaining({
|
||||||
|
requestNo: "TR-20260408-001",
|
||||||
|
estimatedAmount: "3600.00",
|
||||||
|
}),
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
expenseTypeId: AIRFARE_TYPE_ID,
|
||||||
|
description: "去程 航班 上海→深圳",
|
||||||
|
amount: "0.00",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
expenseTypeId: BUS_TYPE_ID,
|
||||||
|
description: "返程 大巴 深圳→上海",
|
||||||
|
amount: "0.00",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.reimbursements[0].linked_travel_request_id).toBe(TRAVEL_ID);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects travel request linking when request is not approved, not owned, or already linked", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
travelRequests: [
|
||||||
|
buildTravelRequest({ id: TRAVEL_ID, status: "draft" }),
|
||||||
|
buildTravelRequest({ id: ALT_TRAVEL_ID, applicant_employee_id: OWNER_ID }),
|
||||||
|
buildTravelRequest({
|
||||||
|
id: "99999999-9999-4999-8999-999999999999",
|
||||||
|
linked_reimbursements: [{ id: "reimbursement-x" }],
|
||||||
|
}),
|
||||||
|
buildTravelRequest({
|
||||||
|
id: "88888888-8888-4888-8888-888888888888",
|
||||||
|
travel_orders: [{ id: "travel-order-1", linked_reimbursement_id: "legacy-linked-reimbursement" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const notApprovedResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const notOwnedResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
linkedTravelRequestId: ALT_TRAVEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const linkedByNewColumnResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
linkedTravelRequestId: "99999999-9999-4999-8999-999999999999",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const linkedByLegacyOrderResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
linkedTravelRequestId: "88888888-8888-4888-8888-888888888888",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notApprovedResponse.statusCode).toBe(409);
|
||||||
|
expect(notOwnedResponse.statusCode).toBe(409);
|
||||||
|
expect(linkedByNewColumnResponse.statusCode).toBe(409);
|
||||||
|
expect(linkedByLegacyOrderResponse.statusCode).toBe(409);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects travel request linking when mapped expense type is missing", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
travelRequests: [
|
||||||
|
buildTravelRequest({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
id: "segment-x",
|
||||||
|
sort_order: 1,
|
||||||
|
segment_role: "outbound",
|
||||||
|
transport_type: "other",
|
||||||
|
from_city: "上海",
|
||||||
|
to_city: "杭州",
|
||||||
|
created_at: new Date("2026-04-08T12:02:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
expenseTypes: [
|
||||||
|
{ id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error.details).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
linkedTravelRequestId: "missing_expense_type_mapping",
|
||||||
|
expenseTypeCode: "transport",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only linkable approved travel requests and supports proxy owner", async () => {
|
||||||
|
findActiveDelegationForDelegateMock.mockResolvedValue({
|
||||||
|
id: "delegation-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
roleCodes: ["administration"],
|
||||||
|
permissionCodes: ["expense.create", "expense.proxy_submit"],
|
||||||
|
},
|
||||||
|
travelRequests: [
|
||||||
|
buildTravelRequest({
|
||||||
|
id: TRAVEL_ID,
|
||||||
|
applicant_employee_id: OWNER_ID,
|
||||||
|
request_no: "TR-OWNER-001",
|
||||||
|
}),
|
||||||
|
buildTravelRequest({
|
||||||
|
id: ALT_TRAVEL_ID,
|
||||||
|
applicant_employee_id: OWNER_ID,
|
||||||
|
linked_reimbursements: [{ id: "linked-reimbursement" }],
|
||||||
|
}),
|
||||||
|
buildTravelRequest({
|
||||||
|
id: "77777777-7777-4777-8777-777777777777",
|
||||||
|
applicant_employee_id: OWNER_ID,
|
||||||
|
travel_orders: [{ id: "travel-order-2", linked_reimbursement_id: "legacy-link" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/reimbursements/linkable-travel-requests?ownerEmployeeId=${OWNER_ID}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({
|
||||||
|
ownerEmployeeId: OWNER_ID,
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: TRAVEL_ID,
|
||||||
|
requestNo: "TR-OWNER-001",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports proxy owner when linking travel request during creation", async () => {
|
||||||
|
findActiveDelegationForDelegateMock.mockResolvedValue({
|
||||||
|
id: "delegation-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
roleCodes: ["administration"],
|
||||||
|
permissionCodes: ["expense.create", "expense.proxy_submit"],
|
||||||
|
},
|
||||||
|
travelRequests: [
|
||||||
|
buildTravelRequest({
|
||||||
|
applicant_employee_id: OWNER_ID,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: {
|
||||||
|
ownerEmployeeId: OWNER_ID,
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
ownerId: OWNER_ID,
|
||||||
|
linkedTravelRequestId: TRAVEL_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户