feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验
这个提交包含在:
@@ -119,6 +119,18 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (code === "VALID_001") {
|
||||
if (details?.amount === "must_be_positive") {
|
||||
return "明细金额必须大于 0。";
|
||||
}
|
||||
|
||||
if (details?.items === "at_least_one_required") {
|
||||
return "请先添加至少 1 条报销明细。";
|
||||
}
|
||||
|
||||
if (details?.items === "positive_amount_required") {
|
||||
return "所有报销明细金额都必须大于 0,请修改后再提交。";
|
||||
}
|
||||
|
||||
const passwordErrors = readStringList(details?.password);
|
||||
if (passwordErrors.length) {
|
||||
return `新密码不符合要求:${passwordErrors.join(";")}。`;
|
||||
|
||||
@@ -46,6 +46,24 @@ export type PrecheckResponse = {
|
||||
} | null;
|
||||
budgetHint: Record<string, unknown> | null;
|
||||
standardChecks: ExpenseStandardCheck[];
|
||||
evidenceSummary?: {
|
||||
invoiceCount: number;
|
||||
attachmentCount: number;
|
||||
items: Array<{
|
||||
itemId: string;
|
||||
linkedInvoiceCount: number;
|
||||
invoiceState: "linked" | "unlinked";
|
||||
}>;
|
||||
};
|
||||
timelinessChecks?: Array<{
|
||||
itemId: string;
|
||||
expenseDate: string;
|
||||
ageDays: number;
|
||||
maxAgeDays: 180 | 365;
|
||||
overdue: boolean;
|
||||
crossCalendarYear: boolean;
|
||||
}>;
|
||||
assetHint?: { required: boolean; itemCount: number };
|
||||
warnings: Array<{ code: string; title: string; detail: string }>;
|
||||
blockingReasons: Array<{ code: string; title: string; detail: string }>;
|
||||
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
||||
|
||||
@@ -301,6 +301,11 @@ export type ReimbursementTravelSummaryPayload = {
|
||||
travelEndDate: string | null;
|
||||
};
|
||||
|
||||
export type ReimbursementUpdatePayload = Partial<ReimbursementTravelSummaryPayload> & {
|
||||
reimbursementDate?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ReimbursementCreateResponse = {
|
||||
id: string;
|
||||
formNumber: string | null;
|
||||
@@ -486,10 +491,24 @@ export async function updateReimbursementTravelSummary(
|
||||
reimbursementId: string,
|
||||
payload: ReimbursementTravelSummaryPayload,
|
||||
) {
|
||||
return apiRequestData<{ travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel"> }>(
|
||||
`/api/reimbursements/${reimbursementId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
);
|
||||
return updateReimbursement(reimbursementId, payload);
|
||||
}
|
||||
|
||||
export async function updateReimbursement(
|
||||
reimbursementId: string,
|
||||
payload: ReimbursementUpdatePayload,
|
||||
) {
|
||||
return apiRequestData<{
|
||||
id: string;
|
||||
status: ReimbursementStatus;
|
||||
reimbursementDate: string;
|
||||
description: string | null;
|
||||
totalAmount: string;
|
||||
travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel">;
|
||||
}>(`/api/reimbursements/${reimbursementId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createReimbursementItem(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateReimbursement } from "../../api/reimbursements";
|
||||
import { isApiError } from "../../api/types";
|
||||
import { mapApiErrorMessage } from "../../api/errorMessages";
|
||||
import { useUnsavedChangesGuard } from "../common/UnsavedChangesGuard";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
type Props = {
|
||||
reimbursementId: string;
|
||||
reimbursementDate: string | null;
|
||||
description: string | null;
|
||||
editable: boolean;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
export function ReimbursementHeaderEditor({
|
||||
reimbursementId,
|
||||
reimbursementDate,
|
||||
description,
|
||||
editable,
|
||||
mobile = false,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [date, setDate] = useState(reimbursementDate ?? "");
|
||||
const [reason, setReason] = useState(description ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const dirty = editing && (date !== (reimbursementDate ?? "") || reason !== (description ?? ""));
|
||||
const runGuarded = useUnsavedChangesGuard(dirty, "报销事由和日期");
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
setDate(reimbursementDate ?? "");
|
||||
setReason(description ?? "");
|
||||
}, [description, editing, reimbursementDate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateReimbursement(reimbursementId, {
|
||||
reimbursementDate: date,
|
||||
description: reason.trim() || null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
setEditing(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(isApiError(mutationError)
|
||||
? mapApiErrorMessage(mutationError.code, mutationError.details, mutationError.message)
|
||||
: "保存报销信息失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className={mobile ? "mt-3" : "mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2"}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-sm leading-6 text-zinc-600">
|
||||
<span className="font-medium text-zinc-800">报销事由:</span>
|
||||
{description || "未填写"}
|
||||
</div>
|
||||
{editable ? (
|
||||
<Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3" data-testid="reimbursement-header-editor">
|
||||
<div className={mobile ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
|
||||
<label className="space-y-1 text-sm text-zinc-700">
|
||||
<span>报销日期</span>
|
||||
<Input type="date" value={date} disabled={mutation.isPending} onChange={(event) => setDate(event.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm text-zinc-700">
|
||||
<span>报销事由</span>
|
||||
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" disabled={!date || mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{mutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={mutation.isPending} onClick={() => runGuarded(() => setEditing(false))}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { PrecheckResponse } from "../../api/feeControl";
|
||||
import type { ReimbursementItem } from "../../api/reimbursements";
|
||||
|
||||
type Props = {
|
||||
precheck: PrecheckResponse | null | undefined;
|
||||
items: ReimbursementItem[];
|
||||
canAccessAssets: boolean;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
export function ReimbursementOperationalHints({ precheck, items, canAccessAssets, mobile = false }: Props) {
|
||||
const evidence = precheck?.evidenceSummary;
|
||||
const timeliness = precheck?.timelinessChecks ?? [];
|
||||
const relevantDates = timeliness.filter((item) => item.overdue || item.crossCalendarYear);
|
||||
const itemById = new Map(items.map((item) => [item.id, item]));
|
||||
|
||||
if (!evidence && !relevantDates.length && !precheck?.assetHint?.required) return null;
|
||||
|
||||
return (
|
||||
<div className={`${mobile ? "mt-3" : "mt-4"} space-y-3`} data-testid={mobile ? "mobile-reimbursement-operational-hints" : "reimbursement-operational-hints"}>
|
||||
{evidence ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-700">
|
||||
<div className="font-medium text-zinc-900">材料对应情况</div>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">
|
||||
已添加发票 {evidence.invoiceCount} 张,整单补充材料 {evidence.attachmentCount} 份。
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-5">
|
||||
{evidence.items.map((entry) => {
|
||||
const item = itemById.get(entry.itemId);
|
||||
return (
|
||||
<li key={entry.itemId}>
|
||||
{item?.expenseType?.name ?? "报销明细"}:
|
||||
{entry.linkedInvoiceCount > 0
|
||||
? `已关联 ${entry.linkedInvoiceCount} 张发票`
|
||||
: "待关联发票或补充无票说明"}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relevantDates.length ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
|
||||
<div className="font-medium">费用日期提示</div>
|
||||
<ul className="mt-1 space-y-1 text-xs leading-5">
|
||||
{relevantDates.map((check) => (
|
||||
<li key={check.itemId}>
|
||||
{itemById.get(check.itemId)?.expenseType?.name ?? "报销明细"}({check.expenseDate}):
|
||||
{[
|
||||
check.overdue ? `距今 ${check.ageDays} 天,超过当前 ${check.maxAgeDays} 天期限` : "",
|
||||
check.crossCalendarYear ? "与当前日期跨自然年度" : "",
|
||||
].filter(Boolean).join(";")},请与财务确认。
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{precheck?.assetHint?.required ? (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-800">
|
||||
<div className="font-medium">资产登记提醒</div>
|
||||
<p className="mt-1 text-xs leading-5">
|
||||
{precheck.assetHint.itemCount} 条明细可能形成资产,报销完成后由管理员核对并手动建卡。
|
||||
</p>
|
||||
{canAccessAssets ? <Link className="mt-2 inline-flex text-xs font-medium text-primary hover:underline" to="/assets">查看资产台账</Link> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { remindWorkflowTask } from "../api/workflow";
|
||||
import type { PrecheckResponse } from "../api/feeControl";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
@@ -44,6 +44,8 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -197,6 +199,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
|
||||
const canEdit = isEditableStatus(detail?.status);
|
||||
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
|
||||
@@ -212,6 +215,12 @@ export function MobileReimbursementDetailPage() {
|
||||
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId && canSubmitExpense && detail),
|
||||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||||
queryFn: () => precheckReimbursement(reimbursementId),
|
||||
});
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedInvoiceFiles(accepted);
|
||||
@@ -919,7 +928,13 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||
<ReimbursementHeaderEditor
|
||||
reimbursementId={reimbursementId}
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
@@ -1037,7 +1052,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mobile-two-action-grid">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !itemForm.amount.trim() || itemSaveMutation.isPending}
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||
>
|
||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||
@@ -1085,6 +1100,16 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-3 text-sm font-medium text-zinc-900">
|
||||
<span>共 {detail.items.length} 条明细</span>
|
||||
<span>报销合计 {formatMoney(detail.totalAmount)}</span>
|
||||
</div>
|
||||
<ReimbursementOperationalHints
|
||||
precheck={reimbursementPrecheckQuery.data}
|
||||
items={detail.items}
|
||||
canAccessAssets={canAccessAssets}
|
||||
mobile
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
|
||||
|
||||
@@ -38,7 +38,7 @@ import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
@@ -47,6 +47,8 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -174,9 +176,10 @@ export function ReimbursementDetailPage() {
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && Boolean(detailQuery.data),
|
||||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||||
queryFn: () => precheckReimbursement(reimbursementId),
|
||||
});
|
||||
@@ -1101,7 +1104,7 @@ export function ReimbursementDetailPage() {
|
||||
<div data-testid="reimbursement-business-summary">
|
||||
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">金额</div>
|
||||
<div className="detail-fact-card__label">报销总额</div>
|
||||
<div className="detail-fact-card__value">{formatMoney(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
@@ -1129,11 +1132,12 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.description ? (
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
{detail.description}
|
||||
</div>
|
||||
) : null}
|
||||
<ReimbursementHeaderEditor
|
||||
reimbursementId={reimbursementId}
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
@@ -1339,8 +1343,20 @@ export function ReimbursementDetailPage() {
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<tfoot className="border-t border-zinc-200 bg-zinc-50 font-medium text-zinc-900">
|
||||
<tr>
|
||||
<TableCell colSpan={2}>共 {detail.items.length} 条明细</TableCell>
|
||||
<TableCell>{formatMoney(detail.totalAmount)}</TableCell>
|
||||
<TableCell colSpan={2}>报销合计</TableCell>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
<ReimbursementOperationalHints
|
||||
precheck={reimbursementPrecheckQuery.data}
|
||||
items={detail.items}
|
||||
canAccessAssets={canAccessAssets}
|
||||
/>
|
||||
|
||||
{itemEditor ? (
|
||||
<ReimbursementItemEditor
|
||||
|
||||
@@ -81,6 +81,7 @@ export function ReimbursementItemEditor({
|
||||
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
|
||||
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
|
||||
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
|
||||
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||
@@ -185,7 +186,8 @@ export function ReimbursementItemEditor({
|
||||
isPending ||
|
||||
!expenseTypeId ||
|
||||
!expenseDate ||
|
||||
!amount.trim()
|
||||
!amount.trim() ||
|
||||
!hasPositiveAmount
|
||||
}
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
|
||||
@@ -66,7 +66,7 @@ function reimbursementListItem() {
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementDetail() {
|
||||
function reimbursementDetail(overrides: { reimbursementDate?: string; description?: string } = {}) {
|
||||
return {
|
||||
id: reimbursementId,
|
||||
formNumber,
|
||||
@@ -77,9 +77,9 @@ function reimbursementDetail() {
|
||||
status: "draft",
|
||||
createdAt: "2026-05-06T09:00:00.000Z",
|
||||
submittedAt: null,
|
||||
reimbursementDate: "2026-05-06",
|
||||
reimbursementDate: overrides.reimbursementDate ?? "2026-05-06",
|
||||
totalAmount: "168.30",
|
||||
description: "客户拜访交通费",
|
||||
description: overrides.description ?? "客户拜访交通费",
|
||||
projectId: null,
|
||||
linkedTravelRequestId: null,
|
||||
linkedTravelRequest: null,
|
||||
@@ -113,6 +113,8 @@ function reimbursementDetail() {
|
||||
|
||||
async function mockApis(page: Page) {
|
||||
let draftExists = true;
|
||||
let detailReimbursementDate = "2026-05-06";
|
||||
let detailDescription = "客户拜访交通费";
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
@@ -245,7 +247,25 @@ async function mockApis(page: Page) {
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, reimbursementDetail());
|
||||
await fulfillJson(route, reimbursementDetail({
|
||||
reimbursementDate: detailReimbursementDate,
|
||||
description: detailDescription,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { reimbursementDate?: string; description?: string | null };
|
||||
detailReimbursementDate = body.reimbursementDate ?? detailReimbursementDate;
|
||||
detailDescription = body.description ?? "";
|
||||
await fulfillJson(route, {
|
||||
id: reimbursementId,
|
||||
status: "draft",
|
||||
reimbursementDate: detailReimbursementDate,
|
||||
description: detailDescription || null,
|
||||
totalAmount: "168.30",
|
||||
travelSummary: { purpose: null, location: null, startDate: null, endDate: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,7 +276,25 @@ async function mockApis(page: Page) {
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||
await fulfillJson(route, { decision: "pass", standardChecks: [], riskFlags: [] });
|
||||
await fulfillJson(route, {
|
||||
entityType: "reimbursement",
|
||||
entityId: reimbursementId,
|
||||
decision: "warn",
|
||||
matchedBudgetControlRule: null,
|
||||
budgetHint: null,
|
||||
standardChecks: [],
|
||||
evidenceSummary: {
|
||||
invoiceCount: 0,
|
||||
attachmentCount: 0,
|
||||
items: [{ itemId, linkedInvoiceCount: 0, invoiceState: "unlinked" }],
|
||||
},
|
||||
timelinessChecks: [],
|
||||
assetHint: { required: false, itemCount: 0 },
|
||||
warnings: [],
|
||||
blockingReasons: [],
|
||||
riskFlags: [],
|
||||
nextActions: ["确认提示后可继续提交"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -369,6 +407,26 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("PC 详情显示权威合计并可编辑报销日期和事由", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-summary")).toContainText("报销总额");
|
||||
await expect(page.getByTestId("reimbursement-items-table")).toContainText("共 1 条明细");
|
||||
await expect(page.getByTestId("reimbursement-items-table")).toContainText("报销合计");
|
||||
await expect(page.getByTestId("reimbursement-operational-hints")).toContainText("待关联发票或补充无票说明");
|
||||
|
||||
await page.getByTestId("reimbursement-business-summary").getByRole("button", { name: "编辑", exact: true }).click();
|
||||
const editor = page.getByTestId("reimbursement-header-editor");
|
||||
await editor.getByLabel("报销日期").fill("2026-05-07");
|
||||
await editor.getByLabel("报销事由").fill("办公网络费用");
|
||||
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("reimbursement-header-editor")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-business-summary")).toContainText("办公网络费用");
|
||||
await expect(page.getByTestId("reimbursement-summary")).toContainText("2026-05-07");
|
||||
});
|
||||
|
||||
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const listRequest = page.waitForRequest((request) => {
|
||||
@@ -386,6 +444,10 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByText(formNumber)).toBeVisible();
|
||||
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||
await expect(page.locator("table:visible")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
|
||||
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("共 1 条明细");
|
||||
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30");
|
||||
});
|
||||
|
||||
test("移动管理员默认查看本人并可切换全部报销", async ({ page }) => {
|
||||
@@ -402,6 +464,22 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await fulfillJson(route, adminMe);
|
||||
});
|
||||
await page.route(`**/api/reimbursements/${otherReimbursementId}`, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
...reimbursementDetail(),
|
||||
id: otherReimbursementId,
|
||||
formNumber: "BX-20260731-0002",
|
||||
status: "reviewing",
|
||||
ownerId: "employee-other-r102",
|
||||
submitterId: "employee-other-r102",
|
||||
owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
|
||||
submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
|
||||
});
|
||||
});
|
||||
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") {
|
||||
|
||||
在新工单中引用
屏蔽一个用户