From 5cd52ee0614c08e84c7471f444876de99b671ed4 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 5 May 2026 22:16:48 +0800 Subject: [PATCH] =?UTF-8?q?R92=20A5-A6=20=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E6=8A=A5=E9=94=80=E5=88=97=E8=A1=A8=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/ReimbursementsPage.tsx | 87 ++++- .../e2e/r92-mobile-invoice-usability.spec.ts | 341 ++++++++++++++++++ ..._21-08-47-r92-mobile-invoice-usability.csv | 7 + 3 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 client/tests/e2e/r92-mobile-invoice-usability.spec.ts create mode 100644 issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx index 61efe3d7..ddd92da0 100644 --- a/client/src/pages/ReimbursementsPage.tsx +++ b/client/src/pages/ReimbursementsPage.tsx @@ -587,7 +587,92 @@ export function ReimbursementsPage() { {!listQuery.isLoading && !listQuery.isError ? ( <> - +
+ {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 ( +
+
+
+
+ {item.formNumber || item.id} +
+
{item.reimbursementDate ?? "未填写日期"}
+
+ +
+ + {item.project ? ( +
+ 项目:{item.project.projectNo} · {item.project.name} +
+ ) : null} + +
+
+
金额
+
+ {item.totalAmount != null + ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format( + Number(item.totalAmount), + ) + : "-"} +
+
+
+
报销人
+
{item.owner?.name ?? "-"}
+
+
+ + {item.pendingWorkflow?.task?.id ? ( +
+ 当前待办:{item.pendingWorkflow.task.stepName} +
+ ) : null} + +
+ {batchMode ? ( + + ) : null} + +
+
+ ); + }) + ) : ( +
+ 暂无数据 +
+ )} +
+ + diff --git a/client/tests/e2e/r92-mobile-invoice-usability.spec.ts b/client/tests/e2e/r92-mobile-invoice-usability.spec.ts new file mode 100644 index 00000000..6f292f39 --- /dev/null +++ b/client/tests/e2e/r92-mobile-invoice-usability.spec.ts @@ -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: "smoke@hyinda.com", + 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); + }); +}); diff --git a/issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv b/issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv new file mode 100644 index 00000000..825b1b01 --- /dev/null +++ b/issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv @@ -0,0 +1,7 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"A1","R92 执行合同冻结","把移动端不可操作和发票上传/OCR 卡住问题收敛为本轮修复范围,不扩展到新业务模块。","Issue CSV 字段完整并通过校验。","命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv`。业务:确认本轮只处理手机可用性、发票上传/OCR 闭环和自动化回归。","manual","DONE","DONE","DONE","issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv","[]","2026-05-05:CSV 已创建并通过校验;回滚路径:删除本轮 CSV;不影响业务代码。" +"A2","修复 OCR provider 与二进制写库问题","OCR 作业使用当前配置 provider,mock OCR 不把 PDF 二进制原文写入 JSON,避免上传后长期 pending 或失败。","PDF/图片上传后 OCR job 可把 mock 结果稳定写库;ocr_provider 与运行配置一致;失败能进入明确 ocr_failed。","命令:`cd server && npm run prisma:validate && npm test -- tests/unit/jobs/ocrJob.test.js tests/unit/routes/invoicePool.upload.test.js`。业务:上传现有发票 PDF 后状态从 pending_ocr 进入 available 或明确失败。","official-sdk|node","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260505211000_r92_ocr_provider_mock/migration.sql|server/src/jobs/ocrJob.js|server/src/services/ocr/mock.js|server/src/routes/invoicePool.js|server/tests/unit/jobs/ocrJob.test.js|server/tests/unit/routes/invoicePool.upload.test.js","[""A1""]","2026-05-05:`prisma validate` 通过,OCR/upload 单测 5 passed;涉及 OCR 任务状态和 Prisma enum 增量迁移;回滚路径:代码恢复后不再写 mock,PostgreSQL enum 新增值保留但不影响旧逻辑;必要时从发布前备份恢复数据库。" +"A3","发票上传状态闭环与失败反馈","上传后列表/详情主动刷新 pending_ocr 状态,ocr_failed 明确提示原因并提供重试或人工修正入口。","用户不会长期只看到上传中或 OCR 处理中;失败原因可见;重试入口可达。","命令:`cd client && npm run typecheck && npm run build`;Playwright 手机视口验证上传后 pending/available/ocr_failed 文案与按钮可见。","playwright|typescript","DONE","DONE","DONE","client/src/pages/InvoicePoolPage.tsx|client/src/pages/invoice-pool/InvoiceDetailPanel.tsx|client/src/pages/invoice-pool/InvoiceListCard.tsx|client/src/lib/invoiceOcr.ts","[""A2""]","2026-05-05:typecheck/build 通过,R92 Playwright 手机回归 3 passed;回滚路径:恢复前端轮询和提示改动。" +"A4","支持批量上传发票","手机上传、票据池上传、报销详情上传支持一次选择多张,按文件展示逐项进度和失败项。","用户可一次选择多张图片/PDF;成功/失败逐项反馈;失败不阻塞其他文件。","命令:`cd client && npm run typecheck && npm run build`;Playwright 验证多文件选择后逐项上传状态。","playwright|typescript","DONE","DONE","DONE","client/src/api/invoicePool.ts|client/src/pages/MobileCapturePage.tsx|client/src/pages/invoice-pool/InvoiceUploadCard.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/ReimbursementDetailPage.tsx","[""A3""]","2026-05-05:票据中心和手机上传页批量上传 Playwright 通过;优先顺序上传,避免并发压垮 OCR;回滚路径:恢复单文件上传逻辑。" +"A5","手机报销列表改为卡片式可操作","小屏下报销列表不再使用横向表格,草稿/驳回单直接显示继续编辑按钮。","390px 手机视口下无横向不可达操作;草稿可直接进入详情继续编辑;列表文字不截断关键操作。","命令:`cd client && npm run typecheck && npm run build`;Playwright 打开 `/reimbursements?status=draft&scope=own` 验证 continue/edit 控件在 viewport 内。","playwright|typescript","DONE","DONE","DONE","client/src/pages/ReimbursementsPage.tsx|client/src/components/patterns/ListWorkspace.tsx","[""A1""]","2026-05-05:R92 Playwright 验证 390px 手机视口下继续编辑入口在 viewport 内;回滚路径:恢复桌面表格渲染。" +"A6","移动端 Playwright 回归与中文提交","用手机视口覆盖报销草稿、票据上传、OCR 状态、票据池详情等路径,修复通过后分批中文提交。","Playwright 产物保存在 output/playwright;本地构建和后端相关测试通过;commit message 中文并引用 R92 Issue ID。","命令:`cd client && npm run test:e2e -- tests/e2e/r92-mobile-invoice-usability.spec.ts --workers=1 --reporter=list`;补充 CLI 截图审查;`git status --short`。","playwright|git|manual","DONE","DONE","DONE","client/tests/e2e|output/playwright|N/A","[""A2"",""A3"",""A4"",""A5""]","2026-05-05:R92 Playwright 手机回归 3 passed;typecheck/build 和后端窄回归通过;输出产物未纳入 git。"