R102/A1-A8 优化报销主流程
这个提交包含在:
@@ -303,6 +303,12 @@ export type ReimbursementItemDeleteResponse = {
|
|||||||
totalAmount: string;
|
totalAmount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReimbursementDeleteResponse = {
|
||||||
|
reimbursementId: string;
|
||||||
|
outcome: "deleted";
|
||||||
|
unboundInvoiceCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReimbursementSubmitResponse = {
|
export type ReimbursementSubmitResponse = {
|
||||||
reimbursementId: string;
|
reimbursementId: string;
|
||||||
formNumber: string;
|
formNumber: string;
|
||||||
@@ -415,6 +421,12 @@ export async function payReimbursement(reimbursementId: string, payload: Reimbur
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteReimbursement(reimbursementId: string) {
|
||||||
|
return apiRequestData<ReimbursementDeleteResponse>(`/api/reimbursements/${reimbursementId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createReimbursementItem(
|
export async function createReimbursementItem(
|
||||||
reimbursementId: string,
|
reimbursementId: string,
|
||||||
payload: ReimbursementItemPayload,
|
payload: ReimbursementItemPayload,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ReimbursementStatus } from "../api/reimbursements";
|
||||||
|
|
||||||
|
type ReimbursementDisplayInput = {
|
||||||
|
formNumber?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
status?: ReimbursementStatus | string | null;
|
||||||
|
reimbursementDate?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isUuidLike(value: string | null | undefined) {
|
||||||
|
return Boolean(value && UUID_PATTERN.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveReimbursementDisplayCode(item: ReimbursementDisplayInput) {
|
||||||
|
const formNumber = item.formNumber?.trim();
|
||||||
|
if (formNumber && !isUuidLike(formNumber)) {
|
||||||
|
return formNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = item.reimbursementDate ?? item.createdAt?.slice(0, 10) ?? "";
|
||||||
|
if (item.status === "draft" || item.status === "rejected") {
|
||||||
|
return date ? `草稿单 · ${date}` : "草稿单";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date ? `未编号报销 · ${date}` : "未编号报销";
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { Button } from "../components/ui/button";
|
|||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||||
|
|
||||||
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||||
@@ -49,12 +50,13 @@ function formatDate(value: string | null | undefined) {
|
|||||||
|
|
||||||
function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
|
function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
|
||||||
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
|
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
|
||||||
|
const displayCode = resolveReimbursementDisplayCode(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-card">
|
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-card">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } 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";
|
||||||
@@ -9,6 +9,7 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/
|
|||||||
import { fetchProjects } from "../api/projects";
|
import { fetchProjects } from "../api/projects";
|
||||||
import {
|
import {
|
||||||
createReimbursementItem,
|
createReimbursementItem,
|
||||||
|
deleteReimbursement,
|
||||||
deleteReimbursementItem,
|
deleteReimbursementItem,
|
||||||
fetchReimbursementDetail,
|
fetchReimbursementDetail,
|
||||||
payReimbursement,
|
payReimbursement,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
import {
|
import {
|
||||||
bindInvoice,
|
bindInvoice,
|
||||||
|
type BindableInvoiceSummary,
|
||||||
fetchBindableInvoices,
|
fetchBindableInvoices,
|
||||||
fetchInvoicePoolDetail,
|
fetchInvoicePoolDetail,
|
||||||
unbindInvoice,
|
unbindInvoice,
|
||||||
@@ -49,6 +51,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
|||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||||
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
import { TEST_IDS } from "../lib/testIds";
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||||
import {
|
import {
|
||||||
@@ -110,6 +113,7 @@ function resolveErpSyncStatus(
|
|||||||
|
|
||||||
export function ReimbursementDetailPage() {
|
export function ReimbursementDetailPage() {
|
||||||
const { reimbursementId = "" } = useParams();
|
const { reimbursementId = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { permissions, me } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -118,6 +122,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||||
const [itemError, setItemError] = useState("");
|
const [itemError, setItemError] = useState("");
|
||||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||||
|
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||||
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
||||||
@@ -156,6 +161,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||||
|
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||||
|
|
||||||
const reimbursementPrecheckQuery = useQuery({
|
const reimbursementPrecheckQuery = useQuery({
|
||||||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||||
@@ -343,6 +349,26 @@ export function ReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteReimbursementMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!reimbursementId) {
|
||||||
|
throw new Error("missing reimbursementId");
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteReimbursement(reimbursementId);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
|
toast.success("草稿已删除");
|
||||||
|
navigate("/reimbursements?status=draft");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = buildActionErrorMessage(error, "删除草稿失败");
|
||||||
|
setActionError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const projectLinkMutation = useMutation({
|
const projectLinkMutation = useMutation({
|
||||||
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
|
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -568,9 +594,15 @@ export function ReimbursementDetailPage() {
|
|||||||
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
|
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
|
||||||
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
|
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
|
||||||
if (selectableIds.length) {
|
if (selectableIds.length) {
|
||||||
|
const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item]));
|
||||||
setSelectedInvoices((previous) => ({
|
setSelectedInvoices((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])),
|
...Object.fromEntries(
|
||||||
|
selectableIds.map((invoiceId) => [
|
||||||
|
invoiceId,
|
||||||
|
previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +611,7 @@ export function ReimbursementDetailPage() {
|
|||||||
...result.ocrFailures,
|
...result.ocrFailures,
|
||||||
];
|
];
|
||||||
if (result.pendingIds.length) {
|
if (result.pendingIds.length) {
|
||||||
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`);
|
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length) {
|
if (messages.length) {
|
||||||
@@ -632,6 +664,7 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
|
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||||
const isPaidReimbursement = detail.status === "paid";
|
const isPaidReimbursement = detail.status === "paid";
|
||||||
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
||||||
const budgetHint = detail.budgetHint;
|
const budgetHint = detail.budgetHint;
|
||||||
@@ -644,6 +677,10 @@ export function ReimbursementDetailPage() {
|
|||||||
canUploadInvoice &&
|
canUploadInvoice &&
|
||||||
canViewOwnInvoice;
|
canViewOwnInvoice;
|
||||||
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense);
|
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense);
|
||||||
|
const canDeleteReimbursement =
|
||||||
|
detail.status === "draft" &&
|
||||||
|
canDeleteExpense &&
|
||||||
|
(isReimbursementParticipant || canViewAllExpense);
|
||||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||||
@@ -749,6 +786,24 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => {
|
||||||
|
if (!detail.items.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.items.length === 1) {
|
||||||
|
return detail.items[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
|
||||||
|
if (!Number.isFinite(invoiceAmount)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
|
||||||
|
return matchedItems.length === 1 ? matchedItems[0].id : "";
|
||||||
|
};
|
||||||
|
|
||||||
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
||||||
setSelectedInvoices((previous) => {
|
setSelectedInvoices((previous) => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
@@ -757,9 +812,10 @@ export function ReimbursementDetailPage() {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId);
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
[invoiceId]: previous[invoiceId] ?? "",
|
[invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -800,8 +856,8 @@ export function ReimbursementDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<PageScaffold>
|
<PageScaffold>
|
||||||
<EntityOverviewHero
|
<EntityOverviewHero
|
||||||
title={`报销详情 ${detail.formNumber ?? detail.id}`}
|
title={`报销详情 ${displayCode}`}
|
||||||
subtitle="查看、编辑、提交。"
|
subtitle="明细、发票和审批。"
|
||||||
meta={(
|
meta={(
|
||||||
<>
|
<>
|
||||||
<StatusBadge status={detail.status} />
|
<StatusBadge status={detail.status} />
|
||||||
@@ -819,6 +875,17 @@ export function ReimbursementDetailPage() {
|
|||||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||||
<Link to="/reimbursements">返回列表</Link>
|
<Link to="/reimbursements">返回列表</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{canDeleteReimbursement ? (
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-delete-draft"
|
||||||
|
disabled={deleteReimbursementMutation.isPending}
|
||||||
|
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
删除草稿
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -848,7 +915,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
||||||
<div>状态:{detail.status}</div>
|
<div>状态:{detail.status}</div>
|
||||||
<div>报销ID:{detail.id}</div>
|
<div>报销编号:{displayCode}</div>
|
||||||
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||||||
<div>金额:{detail.totalAmount ?? "-"}</div>
|
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||||||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||||
@@ -1248,7 +1315,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DetailSideMeta title="报销摘要">
|
<DetailSideMeta title="报销摘要">
|
||||||
<div>报销编号:{detail.formNumber ?? detail.id}</div>
|
<div>报销编号:{displayCode}</div>
|
||||||
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||||||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||||
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
|
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
|
||||||
@@ -1310,7 +1377,7 @@ export function ReimbursementDetailPage() {
|
|||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加发票</DialogTitle>
|
<DialogTitle>添加发票</DialogTitle>
|
||||||
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
|
<DialogDescription>选择可用发票,或上传新发票。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1376,6 +1443,7 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
|
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
|
||||||
<select
|
<select
|
||||||
className="select-std mt-1"
|
className="select-std mt-1"
|
||||||
|
data-testid={`reimbursement-invoice-linked-item-${item.id}`}
|
||||||
value={selectedInvoices[item.id] ?? ""}
|
value={selectedInvoices[item.id] ?? ""}
|
||||||
disabled={!isSelected}
|
disabled={!isSelected}
|
||||||
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
|
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
|
||||||
@@ -1413,7 +1481,7 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
|
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
|
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
|
||||||
<p className="text-xs text-zinc-500">上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。</p>
|
<p className="text-xs text-zinc-500">支持 PDF 或图片,可多选。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
|
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
@@ -1444,7 +1512,7 @@ export function ReimbursementDetailPage() {
|
|||||||
>
|
>
|
||||||
{uploadInvoiceMutation.isPending
|
{uploadInvoiceMutation.isPending
|
||||||
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
|
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
|
||||||
: "上传并等待 OCR"}
|
: "上传发票"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1461,7 +1529,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
{pendingOcrInvoiceIds.length ? (
|
{pendingOcrInvoiceIds.length ? (
|
||||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||||
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后自动刷新。
|
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别,可稍后刷新。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1566,6 +1634,16 @@ export function ReimbursementDetailPage() {
|
|||||||
confirmLabel="删除"
|
confirmLabel="删除"
|
||||||
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDeleteReimbursement}
|
||||||
|
onOpenChange={setConfirmDeleteReimbursement}
|
||||||
|
title={`确认删除 ${displayCode}?`}
|
||||||
|
description="删除后无法恢复,已绑定发票会回到可用状态。"
|
||||||
|
variant="destructive"
|
||||||
|
confirmLabel="删除草稿"
|
||||||
|
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
||||||
|
/>
|
||||||
</PageScaffold>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
return value || "-";
|
return value || "-";
|
||||||
@@ -85,6 +86,7 @@ export function ReimbursementPrintPage() {
|
|||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const totalAmount = detail.totalAmount ?? "0.00";
|
const totalAmount = detail.totalAmount ?? "0.00";
|
||||||
const qrValue = `https://oa.hyinda.com/reimbursements/${detail.id}`;
|
const qrValue = `https://oa.hyinda.com/reimbursements/${detail.id}`;
|
||||||
|
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-100 print:bg-white">
|
<div className="min-h-screen bg-zinc-100 print:bg-white">
|
||||||
@@ -131,7 +133,7 @@ export function ReimbursementPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm text-zinc-500">表单号</div>
|
<div className="text-sm text-zinc-500">表单号</div>
|
||||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{detail.formNumber ?? detail.id}</div>
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{displayCode}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,7 +171,7 @@ export function ReimbursementPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-zinc-500">表单编号</span>
|
<span className="text-zinc-500">表单编号</span>
|
||||||
<span className="font-medium text-zinc-900">{detail.formNumber ?? detail.id}</span>
|
<span className="font-medium text-zinc-900">{displayCode}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-zinc-500">总金额</span>
|
<span className="text-zinc-500">总金额</span>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { exportReimbursements } from "../api/exports";
|
|||||||
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
||||||
import {
|
import {
|
||||||
createReimbursement,
|
createReimbursement,
|
||||||
|
deleteReimbursement,
|
||||||
fetchLinkableTravelRequests,
|
fetchLinkableTravelRequests,
|
||||||
fetchReimbursements,
|
fetchReimbursements,
|
||||||
type ReimbursementCreatePayload,
|
type ReimbursementCreatePayload,
|
||||||
|
type ReimbursementListItem,
|
||||||
type ReimbursementStatus,
|
type ReimbursementStatus,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,7 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Pagination } from "../components/common/Pagination";
|
import { Pagination } from "../components/common/Pagination";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
@@ -107,6 +110,7 @@ export function ReimbursementsPage() {
|
|||||||
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 canCreateExpense = hasPermission(permissions, "expense.create");
|
||||||
|
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||||
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");
|
||||||
@@ -201,6 +205,25 @@ export function ReimbursementsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
|
toast.success("草稿已删除");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error, "删除草稿失败"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteDraft = (item: ReimbursementListItem) => {
|
||||||
|
const displayCode = resolveReimbursementDisplayCode(item);
|
||||||
|
if (!window.confirm(`确认删除 ${displayCode}?删除后无法恢复。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
|
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
|
||||||
onSuccess: (response, format) => {
|
onSuccess: (response, format) => {
|
||||||
@@ -594,6 +617,8 @@ export function ReimbursementsPage() {
|
|||||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||||
);
|
);
|
||||||
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
||||||
|
const displayCode = resolveReimbursementDisplayCode(item);
|
||||||
|
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -604,7 +629,7 @@ export function ReimbursementsPage() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-900">
|
<div className="truncate text-sm font-semibold text-zinc-900">
|
||||||
{item.formNumber || item.id}
|
{displayCode}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,7 +673,7 @@ export function ReimbursementsPage() {
|
|||||||
checked={selectedReimbursementIds.includes(item.id)}
|
checked={selectedReimbursementIds.includes(item.id)}
|
||||||
disabled={!canSelectRow}
|
disabled={!canSelectRow}
|
||||||
onChange={() => toggleBatchSelection(item.id)}
|
onChange={() => toggleBatchSelection(item.id)}
|
||||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
aria-label={`选择报销单 ${displayCode}`}
|
||||||
/>
|
/>
|
||||||
选择
|
选择
|
||||||
</label>
|
</label>
|
||||||
@@ -666,6 +691,18 @@ export function ReimbursementsPage() {
|
|||||||
{actionLabel}
|
{actionLabel}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{canDeleteDraft ? (
|
||||||
|
<Button
|
||||||
|
className="min-h-11"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => handleDeleteDraft(item)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -696,6 +733,8 @@ export function ReimbursementsPage() {
|
|||||||
const canSelectRow = Boolean(
|
const canSelectRow = Boolean(
|
||||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||||
);
|
);
|
||||||
|
const displayCode = resolveReimbursementDisplayCode(item);
|
||||||
|
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||||
@@ -706,7 +745,7 @@ export function ReimbursementsPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedReimbursementIds.includes(item.id)}
|
checked={selectedReimbursementIds.includes(item.id)}
|
||||||
onChange={() => toggleBatchSelection(item.id)}
|
onChange={() => toggleBatchSelection(item.id)}
|
||||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
aria-label={`选择报销单 ${displayCode}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-zinc-400">-</span>
|
<span className="text-xs text-zinc-400">-</span>
|
||||||
@@ -714,10 +753,7 @@ export function ReimbursementsPage() {
|
|||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
<td className="table-td">
|
<td className="table-td">
|
||||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
<div className="font-semibold text-zinc-900">{displayCode}</div>
|
||||||
{item.formNumber && item.formNumber !== item.id ? (
|
|
||||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
|
||||||
) : null}
|
|
||||||
{item.project ? (
|
{item.project ? (
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
项目:{item.project.projectNo} · {item.project.name}
|
项目:{item.project.projectNo} · {item.project.name}
|
||||||
@@ -749,13 +785,26 @@ export function ReimbursementsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||||
<td className="table-td">
|
<td className="table-td">
|
||||||
<Link
|
<div className="flex flex-wrap gap-2">
|
||||||
to={`/reimbursements/${item.id}`}
|
<Link
|
||||||
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
to={`/reimbursements/${item.id}`}
|
||||||
data-testid="reimbursement-detail-link"
|
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||||
>
|
data-testid="reimbursement-detail-link"
|
||||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
>
|
||||||
</Link>
|
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||||
|
</Link>
|
||||||
|
{canDeleteDraft ? (
|
||||||
|
<Button
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => handleDeleteDraft(item)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -819,7 +868,7 @@ export function ReimbursementsPage() {
|
|||||||
共 {selectedItems.length} 项:
|
共 {selectedItems.length} 项:
|
||||||
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
||||||
{selectedItems.map((item) => (
|
{selectedItems.map((item) => (
|
||||||
<div key={item.id}>{item.formNumber ?? item.id}</div>
|
<div key={item.id}>{resolveReimbursementDisplayCode(item)}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,7 +923,9 @@ export function ReimbursementsPage() {
|
|||||||
<div key={`${item.taskId}-${item.outcome}`} className="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
<div key={`${item.taskId}-${item.outcome}`} className="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-zinc-900">{item.formNumber ?? item.reimbursementId ?? "-"}</div>
|
<div className="font-medium text-zinc-900">
|
||||||
|
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-zinc-700">
|
<div className="text-sm font-medium text-zinc-700">
|
||||||
|
|||||||
@@ -72,24 +72,26 @@ export function ReimbursementActions({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
{status === "reviewing" ? (
|
||||||
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||||
<Textarea
|
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
||||||
data-testid="reimbursement-withdraw-comment"
|
<Textarea
|
||||||
onChange={(event) => setWithdrawComment(event.target.value)}
|
data-testid="reimbursement-withdraw-comment"
|
||||||
rows={2}
|
onChange={(event) => setWithdrawComment(event.target.value)}
|
||||||
value={withdrawComment}
|
rows={2}
|
||||||
/>
|
value={withdrawComment}
|
||||||
<Button
|
/>
|
||||||
data-testid="reimbursement-withdraw"
|
<Button
|
||||||
variant="secondary"
|
data-testid="reimbursement-withdraw"
|
||||||
disabled={isPending || status !== "reviewing" || withdrawComment.trim().length < 1}
|
variant="secondary"
|
||||||
onClick={() => onWithdraw(withdrawComment.trim())}
|
disabled={isPending || withdrawComment.trim().length < 1}
|
||||||
type="button"
|
onClick={() => onWithdraw(withdrawComment.trim())}
|
||||||
>
|
type="button"
|
||||||
撤回
|
>
|
||||||
</Button>
|
撤回
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canPay ? (
|
{canPay ? (
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||||
@@ -114,11 +116,11 @@ export function ReimbursementActions({
|
|||||||
}
|
}
|
||||||
value={paymentChannel}
|
value={paymentChannel}
|
||||||
>
|
>
|
||||||
<option value="bank">bank</option>
|
<option value="bank">银行转账</option>
|
||||||
<option value="alipay">alipay</option>
|
<option value="alipay">支付宝</option>
|
||||||
<option value="wechat">wechat</option>
|
<option value="wechat">微信</option>
|
||||||
<option value="cash">cash</option>
|
<option value="cash">现金</option>
|
||||||
<option value="other">other</option>
|
<option value="other">其他</option>
|
||||||
</select>
|
</select>
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursement-pay"
|
data-testid="reimbursement-pay"
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
function envelope(data: unknown) {
|
||||||
|
return { requestId: "mock-r102-reimbursement", data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(data)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = {
|
||||||
|
id: "employee-r102",
|
||||||
|
employeeNo: "E102",
|
||||||
|
name: "Employee",
|
||||||
|
phone: "13800000000",
|
||||||
|
email: "[email protected]",
|
||||||
|
departmentId: "dept-r102",
|
||||||
|
directManagerId: null,
|
||||||
|
status: "active",
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
roleCodes: ["employee"],
|
||||||
|
profileCompleteness: {
|
||||||
|
hasEmergencyContact: true,
|
||||||
|
hasBankAccount: true,
|
||||||
|
hasAvatar: false,
|
||||||
|
hasIdNumber: false,
|
||||||
|
score: 50,
|
||||||
|
},
|
||||||
|
wecomBinding: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const itemId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const invoiceId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const formNumber = "BX-20260506-0001";
|
||||||
|
|
||||||
|
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||||
|
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reimbursementListItem() {
|
||||||
|
return {
|
||||||
|
id: reimbursementId,
|
||||||
|
formNumber,
|
||||||
|
status: "draft",
|
||||||
|
reimbursementDate: "2026-05-06",
|
||||||
|
totalAmount: "168.30",
|
||||||
|
description: "客户拜访交通费",
|
||||||
|
payment: {
|
||||||
|
paymentVoucherNo: null,
|
||||||
|
paymentDate: null,
|
||||||
|
paymentChannel: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
},
|
||||||
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
project: null,
|
||||||
|
pendingWorkflow: null,
|
||||||
|
createdAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reimbursementDetail() {
|
||||||
|
return {
|
||||||
|
id: reimbursementId,
|
||||||
|
formNumber,
|
||||||
|
submitterId: me.id,
|
||||||
|
ownerId: me.id,
|
||||||
|
departmentId: me.departmentId,
|
||||||
|
department: { id: "dept-r102", name: "研发部" },
|
||||||
|
status: "draft",
|
||||||
|
createdAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
submittedAt: null,
|
||||||
|
reimbursementDate: "2026-05-06",
|
||||||
|
totalAmount: "168.30",
|
||||||
|
description: "客户拜访交通费",
|
||||||
|
projectId: null,
|
||||||
|
linkedTravelRequestId: null,
|
||||||
|
linkedTravelRequest: null,
|
||||||
|
project: null,
|
||||||
|
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
|
||||||
|
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: itemId,
|
||||||
|
expenseTypeId: "expense-transport",
|
||||||
|
expenseDate: "2026-05-06",
|
||||||
|
amount: "168.30",
|
||||||
|
description: "客户拜访交通费",
|
||||||
|
expenseType: {
|
||||||
|
id: "expense-transport",
|
||||||
|
code: "transport",
|
||||||
|
name: "交通费",
|
||||||
|
category: "travel",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invoices: [],
|
||||||
|
budgetHint: null,
|
||||||
|
pendingWorkflow: null,
|
||||||
|
workflowProgress: null,
|
||||||
|
approvalHistory: [],
|
||||||
|
riskFlags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockApis(page: Page) {
|
||||||
|
let draftExists = true;
|
||||||
|
|
||||||
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path === "/api/auth/sso/providers") {
|
||||||
|
await fulfillJson(route, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/auth/wecom/config") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
enabled: false,
|
||||||
|
entryEnabled: false,
|
||||||
|
oauthEnabled: false,
|
||||||
|
corpId: "",
|
||||||
|
agentId: "",
|
||||||
|
loginUrl: null,
|
||||||
|
bindEnabled: false,
|
||||||
|
defaultReturnTo: "/portal",
|
||||||
|
status: "disabled",
|
||||||
|
statusReasons: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||||
|
body: JSON.stringify(envelope({ accessToken: "mock-r102-token", expiresIn: 900, mustChangePassword: false })),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/me") {
|
||||||
|
await fulfillJson(route, me);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/system/navigation") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: {
|
||||||
|
id: "workspace-r102",
|
||||||
|
persisted: true,
|
||||||
|
profile: "small_team_internal",
|
||||||
|
orgMode: "single_company",
|
||||||
|
primaryEntryMode: "password",
|
||||||
|
notificationStrategy: "in_app",
|
||||||
|
devicePriority: "desktop",
|
||||||
|
moduleVisibility: {},
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
main: [
|
||||||
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
|
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
||||||
|
],
|
||||||
|
admin: [],
|
||||||
|
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
|
||||||
|
},
|
||||||
|
recommendedRolePresets: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/portal/home") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: { id: "workspace-r102", persisted: true, profile: "small_team_internal" },
|
||||||
|
greeting: { name: me.name },
|
||||||
|
cards: [],
|
||||||
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||||
|
myApplications: [{ domain: "reimbursement", count: draftExists ? 1 : 0, href: "/reimbursements" }],
|
||||||
|
riskSummary: null,
|
||||||
|
announcements: [],
|
||||||
|
quickLinks: [],
|
||||||
|
todayPresence: null,
|
||||||
|
teamPresence: null,
|
||||||
|
weeklyReport: null,
|
||||||
|
weeklySummary: null,
|
||||||
|
todoSummary: null,
|
||||||
|
projectSummary: null,
|
||||||
|
peopleLifecycle: null,
|
||||||
|
adoptionSummary: null,
|
||||||
|
recentActivities: [],
|
||||||
|
teamStatusBoard: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/reimbursement-templates") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/reimbursements/linkable-travel-requests") {
|
||||||
|
await fulfillJson(route, { ownerEmployeeId: me.id, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/reimbursements" && request.method() === "GET") {
|
||||||
|
const items = draftExists ? [reimbursementListItem()] : [];
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: "own", items });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/reimbursements" && request.method() === "POST") {
|
||||||
|
draftExists = true;
|
||||||
|
await fulfillJson(route, {
|
||||||
|
id: reimbursementId,
|
||||||
|
formNumber,
|
||||||
|
submitterId: me.id,
|
||||||
|
ownerId: me.id,
|
||||||
|
reimbursementDate: "2026-05-06",
|
||||||
|
status: "draft",
|
||||||
|
description: null,
|
||||||
|
projectId: null,
|
||||||
|
project: null,
|
||||||
|
totalAmount: "0",
|
||||||
|
linkedTravelRequestId: null,
|
||||||
|
linkedTravelRequest: null,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, reimbursementDetail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
|
||||||
|
draftExists = false;
|
||||||
|
await fulfillJson(route, { reimbursementId, outcome: "deleted", unboundInvoiceCount: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||||
|
await fulfillJson(route, { decision: "pass", standardChecks: [], riskFlags: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/expense-types") {
|
||||||
|
await fulfillJson(route, { items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/projects") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/invoice-pool/bindable") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: invoiceId,
|
||||||
|
invoiceCode: "26112000000938423851",
|
||||||
|
invoiceNumber: "38423851",
|
||||||
|
invoiceDate: "2026-05-06",
|
||||||
|
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||||
|
totalAmount: "168.30",
|
||||||
|
originalName: "发票.pdf",
|
||||||
|
createdAt: "2026-05-06T09:30:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/api/invoice-pool/${invoiceId}/bind`) {
|
||||||
|
await fulfillJson(route, { outcome: "bound" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/notifications") {
|
||||||
|
await fulfillJson(route, { items: [], total: 0, page: 1, pageSize: 5, unreadCount: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||||
|
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
||||||
|
await page.getByTestId("login-submit").click();
|
||||||
|
await expect(page).toHaveURL(/\/portal/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("R102 reimbursement main flow", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockApis(page);
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PC 列表显示业务编号并可删除草稿", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await page.goto("/reimbursements?status=draft");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
|
||||||
|
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||||
|
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
expect(dialog.message()).toContain(formNumber);
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "删除" }).first().click();
|
||||||
|
await expect(page.getByRole("cell", { name: "暂无数据" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("详情页发票默认绑定唯一明细", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
|
||||||
|
await expect(page.getByText("报销ID")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||||||
|
await page.getByRole("checkbox").first().check();
|
||||||
|
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveValue(itemId);
|
||||||
|
|
||||||
|
const uploadInput = page.getByTestId("reimbursement-invoice-upload-file");
|
||||||
|
await expect(uploadInput).toHaveAttribute("multiple", "");
|
||||||
|
await expect(page.getByRole("button", { name: "上传发票" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/mobile/reimbursements");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "报销" })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
|
||||||
|
await expect(page.getByText(formNumber)).toBeVisible();
|
||||||
|
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||||
|
await expect(page.locator("table:visible")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
A1,R102 报销主流程合同,"以 R100 设计规则和 R101 移动端收口为基础,集中修复报销编号、草稿删除、发票默认绑定、上传交互和页面啰嗦问题。","本 CSV 校验通过;明确本批仅处理报销主流程,不重构采购/差旅/票据中心全局页面。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_22-34-54-r102-reimbursement-main-flow.csv;业务流程影响:固定报销主流程改造范围。","python|manual",DONE,DONE,DONE,"issues/2026-05-06_22-34-54-r102-reimbursement-main-flow.csv","[]","涉及权限和状态规则;回滚路径为 revert 本批提交,若涉及 Prisma 迁移需单独说明。"
|
||||||
|
A2,报销业务编号与显示兜底,"创建报销草稿时生成业务编号,前端列表、详情、打印和移动卡片不再把 UUID 作为主展示;历史无编号草稿显示 草稿单 或日期摘要。","新建报销接口返回 formNumber;列表和详情主标题不显示 UUID;静态诊断中报销 UUID 主展示问题减少。","server: npm run prisma:validate && node --check src/routes/reimbursements.js;client: npm run typecheck;业务流程:员工新建报销草稿后列表和详情显示业务编号或草稿摘要。","node|prisma|typescript",DONE,DONE,DONE,"server/src/routes/reimbursements.js|client/src/lib/reimbursementDisplay.ts|client/src/pages/ReimbursementsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/ReimbursementPrintPage.tsx|client/src/pages/MobileReimbursementsPage.tsx","[""A1""]","优先复用 server/src/services/documentSequence.js;不为历史数据强制迁移,避免生产风险。"
|
||||||
|
A3,草稿删除入口,"前端暴露删除草稿入口,调用现有 DELETE /reimbursements/:id;仅 draft 状态允许删除,reviewing 使用撤回,approved/paid 不提供删除。","报销列表和详情页草稿状态可删除且二次确认;非草稿不显示删除;删除后返回列表并刷新。","cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --reporter=list;server node --check src/routes/reimbursements.js;业务流程:创建草稿→列表删除;创建草稿→详情删除。","playwright|typescript|node",DONE,DONE,DONE,"client/src/api/reimbursements.ts|client/src/pages/ReimbursementsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|server/src/routes/reimbursements.js","[""A2""]","后端已有 draft 删除能力时只补前端;若后端缺少权限校验则本批补齐。"
|
||||||
|
A4,发票默认绑定明细,"添加发票弹窗中,报销只有一条明细时默认绑定该明细;多条明细时优先按发票金额与明细金额匹配;无法匹配时允许不绑定具体明细。","打开添加发票弹窗时,单明细场景下可绑定发票的下拉默认选中唯一明细;多明细同金额时选中匹配明细;用户仍可改为整单绑定。","cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --reporter=list;业务流程:草稿含一条明细→添加发票→默认绑定明细→绑定成功。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx","[""A2""]","不改变后端绑定 API;只优化前端默认选择算法。"
|
||||||
|
A5,发票上传交互精简与批量,"添加发票区域支持多选文件、中文文件回显、上传进度和失败保留;文案从解释型改为任务型。","上传控件允许 multiple;按钮文案为 上传发票;上传中显示完成数;失败文件保留;不暴露原生英文上传按钮。","cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --reporter=list;业务流程:添加发票弹窗选择多个文件→上传按钮状态可见→失败提示可读。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx|client/src/api/invoicePool.ts|scripts/qa/check-ui-design-rules.mjs","[""A4""]","OCR 后端调用保持现状;若发现长期处理中,仅前端显示可重试/刷新提示,后续 OCR 专项再深改。"
|
||||||
|
A6,报销页面文案与结构精简,"按 R100 规则精简报销列表、详情和添加发票弹窗的解释型文案,突出状态、金额、明细、发票和主操作。","报销列表副标题和详情说明更短;添加发票弹窗不再出现长段流程解释;PC 和移动首屏更简洁。","node scripts/qa/check-ui-design-rules.mjs;cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --reporter=list;业务流程:员工从列表进入草稿详情并添加发票,首屏关键操作清晰。","node|playwright|typescript",DONE,DONE,DONE,"client/src/pages/ReimbursementsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/reimbursement-detail/ReimbursementActions.tsx|client/src/pages/MobileReimbursementsPage.tsx","[""A2"",""A3"",""A4"",""A5""]","不做全站设计重构;采购/差旅留给 R103。"
|
||||||
|
A7,报销主流程自动化验证,"新增 R102 Playwright 用例覆盖新建草稿编号显示、删除草稿、添加发票默认绑定、批量上传控件和移动报销卡片。","R102 用例通过;覆盖 PC 390/1440 关键路径;失败截图可用于后续修复。","cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --reporter=list;业务流程:新建草稿→查看列表/详情→添加发票默认绑定→删除草稿→移动报销查看。","playwright",DONE,DONE,DONE,"client/tests/e2e/r102-reimbursement-main-flow.spec.ts","[""A2"",""A3"",""A4"",""A5"",""A6""]","使用 mock API,不依赖生产数据;后续可追加 live smoke。"
|
||||||
|
A8,验证回填与中文提交,"执行 CSV 校验、后端语法/Prisma、前端 typecheck/build、UI 诊断、R102 Playwright,并中文提交。","A1-A7 状态按实测回填;git diff --check 通过;提交信息中文且引用 R102/A1-A8。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_22-34-54-r102-reimbursement-main-flow.csv;cd server && npm run prisma:validate && node --check src/routes/reimbursements.js;cd client && npm run build;node scripts/qa/check-ui-design-rules.mjs;cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --reporter=list;业务流程:确认报销主流程变更可构建可回归。","python|node|npm|playwright|git",DONE,DONE,DONE,"issues/2026-05-06_22-34-54-r102-reimbursement-main-flow.csv|server/src/routes/reimbursements.js|client","[""A1"",""A2"",""A3"",""A4"",""A5"",""A6"",""A7""]","提交后进入 R103 跨页面一致性。"
|
||||||
|
@@ -1417,8 +1417,15 @@ async function reimbursementRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const issued = await issueDocumentNumber(tx, {
|
||||||
|
documentType: "reimbursement",
|
||||||
|
actorId: request.employee.id,
|
||||||
|
timeZone: fastify.config.appTimezone,
|
||||||
|
});
|
||||||
|
|
||||||
const created = await tx.reimbursementForm.create({
|
const created = await tx.reimbursementForm.create({
|
||||||
data: {
|
data: {
|
||||||
|
form_number: issued.documentNumber,
|
||||||
submitter_id: request.employee.id,
|
submitter_id: request.employee.id,
|
||||||
owner_id: ownerEmployeeId,
|
owner_id: ownerEmployeeId,
|
||||||
department_id: ownerContext.departmentId,
|
department_id: ownerContext.departmentId,
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户