R92 A5-A6 修复手机报销列表并补自动化回归
这个提交包含在:
@@ -587,7 +587,92 @@ export function ReimbursementsPage() {
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<>
|
||||
<PrimaryTable>
|
||||
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const canSelectRow = Boolean(
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card-std space-y-3 p-4"
|
||||
data-testid="reimbursement-mobile-card"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">
|
||||
{item.formNumber || item.id}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
||||
</div>
|
||||
<StatusBadge className="shrink-0" status={item.status} />
|
||||
</div>
|
||||
|
||||
{item.project ? (
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
项目:{item.project.projectNo} · {item.project.name}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">金额</div>
|
||||
<div className="font-semibold text-zinc-900">
|
||||
{item.totalAmount != null
|
||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
||||
Number(item.totalAmount),
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="truncate font-medium text-zinc-900">{item.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.pendingWorkflow?.task?.id ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
当前待办:{item.pendingWorkflow.task.stepName}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{batchMode ? (
|
||||
<label className="flex min-h-10 items-center gap-2 rounded-lg border border-zinc-200 px-3 text-sm text-zinc-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
disabled={!canSelectRow}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||
/>
|
||||
选择
|
||||
</label>
|
||||
) : null}
|
||||
<Button asChild className="min-h-10 flex-1" size="sm" variant="secondary">
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
data-testid="reimbursement-mobile-detail-link"
|
||||
>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PrimaryTable className="hidden md:block">
|
||||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
import { ADMIN } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "r92-mock", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
async function mockAuthenticatedShell(page: Page) {
|
||||
await page.route((url) => url.pathname === "/api/auth/sso/providers", (route) => fulfillJson(route, []));
|
||||
await page.route((url) => url.pathname === "/api/auth/wecom/config", (route) =>
|
||||
fulfillJson(route, {
|
||||
enabled: false,
|
||||
entryEnabled: false,
|
||||
oauthEnabled: false,
|
||||
corpId: "",
|
||||
agentId: "",
|
||||
loginUrl: null,
|
||||
bindEnabled: false,
|
||||
defaultReturnTo: "/mobile",
|
||||
status: "disabled",
|
||||
statusReasons: ["disabled"],
|
||||
}),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/auth/login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({
|
||||
accessToken: "r92-access-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await page.route((url) => url.pathname === "/api/auth/refresh", (route) =>
|
||||
fulfillJson(route, {
|
||||
accessToken: "r92-access-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/me", (route) =>
|
||||
fulfillJson(route, {
|
||||
id: "employee-mock-1",
|
||||
employeeNo: "E001",
|
||||
name: "Smoke Employee",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-1",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["admin"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 50,
|
||||
},
|
||||
wecomBinding: null,
|
||||
}),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/system/navigation", (route) =>
|
||||
fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-1",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "mobile",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "mobile", href: "/mobile", label: "移动工作台", icon: "phone_iphone", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "expense", href: "/reimbursements", label: "我的报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "invoice", href: "/invoice-pool", label: "票据中心", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
}),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/notifications", (route) =>
|
||||
fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: true, unreadCount: 0, items: [] }),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/portal/home", (route) =>
|
||||
fulfillJson(route, {
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "Smoke Employee" },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/todos" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
todayPresence: null,
|
||||
teamPresence: null,
|
||||
weeklyReport: null,
|
||||
weeklySummary: null,
|
||||
todoSummary: null,
|
||||
projectSummary: null,
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function reimbursementListItem() {
|
||||
return {
|
||||
id: "19439274-66ff-4301-964d-fcb43c304454",
|
||||
formNumber: null,
|
||||
status: "draft",
|
||||
reimbursementDate: "2026-05-05",
|
||||
totalAmount: "0.00",
|
||||
description: null,
|
||||
payment: {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
},
|
||||
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
submitter: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
project: null,
|
||||
pendingWorkflow: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockReimbursements(page: Page) {
|
||||
await page.route((url) => url.pathname === "/api/reimbursement-templates", (route) =>
|
||||
fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/reimbursements/linkable-travel-requests", (route) =>
|
||||
fulfillJson(route, { items: [] }),
|
||||
);
|
||||
await page.route((url) => url.pathname === "/api/reimbursements", (route) =>
|
||||
fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
scope: "own",
|
||||
items: [reimbursementListItem()],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const invoiceListItem = {
|
||||
id: "invoice-pending-1",
|
||||
status: "pending_ocr",
|
||||
ocrType: "vat",
|
||||
ocrProvider: "mock",
|
||||
ocrAttempts: 0,
|
||||
ocrLastError: null,
|
||||
ocrCompletedAt: null,
|
||||
invoiceCode: null,
|
||||
invoiceNumber: null,
|
||||
invoiceDate: null,
|
||||
amount: null,
|
||||
taxAmount: null,
|
||||
totalAmount: null,
|
||||
linkedReimbursementId: null,
|
||||
linkedItemId: null,
|
||||
linkedTravelOrderId: null,
|
||||
originalName: "pending.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 128,
|
||||
verification: null,
|
||||
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
uploadedBy: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function invoiceDetail(id: string, status: "pending_ocr" | "available" = "available") {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
archivedReason: null,
|
||||
archivedAt: null,
|
||||
ownerEmployeeId: "employee-mock-1",
|
||||
uploadedByEmployeeId: "employee-mock-1",
|
||||
originalName: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 128,
|
||||
fileSha256: `sha-${id}`,
|
||||
remarks: null,
|
||||
ocrType: "vat",
|
||||
ocrProvider: "mock",
|
||||
ocrAttempts: status === "pending_ocr" ? 0 : 1,
|
||||
ocrLastRequestedAt: null,
|
||||
ocrLastError: null,
|
||||
ocrCompletedAt: status === "pending_ocr" ? null : "2026-05-05T13:00:00.000Z",
|
||||
invoiceCode: status === "available" ? "MOCKCODE" : null,
|
||||
invoiceNumber: status === "available" ? "MOCKNO" : null,
|
||||
invoiceDate: status === "available" ? "2026-05-05" : null,
|
||||
sellerName: status === "available" ? "Mock Seller" : null,
|
||||
buyerName: status === "available" ? "Mock Buyer" : null,
|
||||
amount: status === "available" ? "100.00" : null,
|
||||
taxAmount: status === "available" ? "10.00" : null,
|
||||
totalAmount: status === "available" ? "110.00" : null,
|
||||
linkedReimbursementId: null,
|
||||
linkedItemId: null,
|
||||
linkedTravelOrderId: null,
|
||||
verification: null,
|
||||
isDuplicateOverride: false,
|
||||
overrideRefFormNumber: null,
|
||||
overrideReason: null,
|
||||
overriddenBy: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockInvoicePool(page: Page, uploadCounter: { value: number }) {
|
||||
await page.route((url) => url.pathname === "/api/travel-requests", (route) =>
|
||||
fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] }),
|
||||
);
|
||||
await page.route((url) => url.pathname.startsWith("/api/invoice-pool"), (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (route.request().method() === "POST" && pathname === "/api/invoice-pool/upload") {
|
||||
uploadCounter.value += 1;
|
||||
return fulfillJson(route, {
|
||||
invoicePoolId: `invoice-upload-${uploadCounter.value}`,
|
||||
status: uploadCounter.value === 1 ? "pending_ocr" : "available",
|
||||
fileSha256: `sha-upload-${uploadCounter.value}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/verification")) {
|
||||
return fulfillJson(route, { invoiceId: "invoice-pending-1", items: [] });
|
||||
}
|
||||
|
||||
if (pathname === "/api/invoice-pool") {
|
||||
return fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
ownerScope: "own",
|
||||
items: [invoiceListItem],
|
||||
});
|
||||
}
|
||||
|
||||
const id = pathname.split("/").at(-1) ?? "invoice-pending-1";
|
||||
return fulfillJson(
|
||||
route,
|
||||
invoiceDetail(id, id === "invoice-pending-1" || id === "invoice-upload-1" ? "pending_ocr" : "available"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loginThenUseMobileViewport(page: Page) {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/login");
|
||||
await page.getByTestId(TEST_IDS.login.identifier).fill(ADMIN.identifier);
|
||||
await page.getByTestId(TEST_IDS.login.password).fill(ADMIN.password);
|
||||
await page.getByTestId(TEST_IDS.login.submit).click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
test.describe("R92 手机端票据与报销可用性", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockAuthenticatedShell(page);
|
||||
await mockReimbursements(page);
|
||||
});
|
||||
|
||||
test("报销草稿在手机视口使用卡片并暴露继续编辑入口", async ({ page }) => {
|
||||
await loginThenUseMobileViewport(page);
|
||||
await page.goto("/reimbursements?status=draft&scope=own");
|
||||
|
||||
await expect(page.getByTestId("reimbursement-mobile-card")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursements-table")).toBeHidden();
|
||||
|
||||
const editLink = page.getByTestId("reimbursement-mobile-detail-link");
|
||||
await expect(editLink).toHaveText("继续编辑");
|
||||
|
||||
const box = await editLink.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
|
||||
});
|
||||
|
||||
test("票据中心手机视口可批量选择并顺序上传", async ({ page }) => {
|
||||
const uploadCounter = { value: 0 };
|
||||
await mockInvoicePool(page, uploadCounter);
|
||||
await loginThenUseMobileViewport(page);
|
||||
await page.goto("/invoice-pool");
|
||||
|
||||
await expect(page.getByTestId("invoice-mobile-row")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-pool-table")).toBeHidden();
|
||||
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles([
|
||||
{ name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
|
||||
{ name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
|
||||
]);
|
||||
await expect(page.getByText("已选择 2 个文件")).toBeVisible();
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect.poll(() => uploadCounter.value).toBe(2);
|
||||
});
|
||||
|
||||
test("手机票据上传页支持一次选择多张并逐张上传", async ({ page }) => {
|
||||
const uploadCounter = { value: 0 };
|
||||
await mockInvoicePool(page, uploadCounter);
|
||||
await loginThenUseMobileViewport(page);
|
||||
await page.goto("/mobile/capture");
|
||||
|
||||
await page.getByTestId("mobile-capture-file").setInputFiles([
|
||||
{ name: "mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-a") },
|
||||
{ name: "mobile-b.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-b") },
|
||||
]);
|
||||
await expect(page.getByText("已选择 2 个")).toBeVisible();
|
||||
await page.getByTestId("mobile-capture-upload").click();
|
||||
|
||||
await expect.poll(() => uploadCounter.value).toBe(2);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户