V1-V6 收口移动视口与 PDF 发票上传

这个提交包含在:
2026-05-10 23:30:13 +08:00
父节点 3c9cd3aa41
当前提交 491b42a232
共修改 47 个文件,包含 387 行新增和 244 行删除
+4
查看文件
@@ -308,6 +308,10 @@ export function mapApiErrorMessage(
return "当前假种要求上传附件后才能提交。";
}
if (details?.file === "pdf_invoice_required") {
return "请上传 PDF 发票文件。";
}
if (details?.file === "unsupported_mime_type") {
return "附件类型不被当前假种接受,请重新选择。";
}
@@ -33,7 +33,10 @@ export function MobileBottomNav() {
const pendingCount = pendingCountQuery.data ?? 0;
return (
<nav className="z-20 shrink-0 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-1.5 shadow-[0_-6px_18px_rgba(15,23,42,0.06)] backdrop-blur md:hidden">
<nav
className="fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.35rem,env(safe-area-inset-bottom))] pt-1.5 shadow-[0_-6px_18px_rgba(15,23,42,0.08)] backdrop-blur md:hidden"
data-testid="mobile-bottom-nav"
>
<div className="grid grid-cols-4 gap-1">
{MOBILE_NAV_ITEMS.map((item) => {
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
@@ -45,13 +48,13 @@ export function MobileBottomNav() {
key={item.key}
type="button"
data-testid={`mobile-nav-${item.key}`}
className="touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[11px] font-medium leading-none text-zinc-400"
className="touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none text-zinc-400"
disabled
aria-disabled="true"
title={`${item.label}不可用`}
>
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
<span>{item.label}</span>
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
}
@@ -64,7 +67,7 @@ export function MobileBottomNav() {
data-testid={`mobile-nav-${item.key}`}
className={({ isActive }) =>
cn(
"touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[11px] font-medium leading-none transition-colors",
"touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none transition-colors",
isActive
? "bg-primary text-white shadow-sm"
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
@@ -86,7 +89,7 @@ export function MobileBottomNav() {
</span>
) : null}
</span>
<span>{item.label}</span>
<span className="whitespace-nowrap">{item.label}</span>
</>
)}
</NavLink>
+1 -1
查看文件
@@ -26,7 +26,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/workflow/tasks", title: "审批待办", group: "主导航" },
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
{ path: "/mobile/capture", title: "移动拍照上传", group: "移动工作台" },
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
+27 -2
查看文件
@@ -17,6 +17,28 @@ export function AppShellLayout() {
? resolveMobileEquivalentPath(location.pathname, location.search)
: null;
useEffect(() => {
if (!isMobileRoute || typeof window === "undefined") {
return;
}
const updateViewportHeight = () => {
const height = window.visualViewport?.height ?? window.innerHeight;
document.documentElement.style.setProperty("--mobile-viewport-height", `${height}px`);
};
updateViewportHeight();
window.addEventListener("resize", updateViewportHeight);
window.addEventListener("orientationchange", updateViewportHeight);
window.visualViewport?.addEventListener("resize", updateViewportHeight);
return () => {
window.removeEventListener("resize", updateViewportHeight);
window.removeEventListener("orientationchange", updateViewportHeight);
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
};
}, [isMobileRoute]);
useEffect(() => {
const pathname = location.pathname;
if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) {
@@ -33,10 +55,13 @@ export function AppShellLayout() {
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
{isMobileRoute ? null : <Sidebar />}
<main className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "h-[100dvh] overflow-hidden" : "lg:ml-72"}`}>
<main
className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "overflow-hidden" : "lg:ml-72"}`}
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
>
{isMobileRoute ? null : <Topbar />}
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? "min-h-0 overflow-y-auto pb-5 pt-4 sm:px-5 sm:py-5" : "sm:px-7 sm:py-7 lg:px-8"}`}>
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overflow-y-auto pt-4 sm:px-5 sm:pt-5 ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,64px)+env(safe-area-inset-bottom,0px)+1rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
<div className="layout-container max-w-none">
<ErrorBoundary>
<Outlet />
+4 -48
查看文件
@@ -174,7 +174,7 @@ export function MobileCapturePage() {
<PageScaffold className="space-y-4">
<PageHeader
title="票据上传"
subtitle="拍照或选择文件。"
subtitle="上传 PDF 发票文件。"
/>
{error ? (
@@ -189,7 +189,7 @@ export function MobileCapturePage() {
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
<p className="mt-1 text-sm leading-5 text-zinc-500">图片或 PDF。</p>
<p className="mt-1 text-sm leading-5 text-zinc-500">仅支持 PDF 发票。</p>
</div>
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
{isOnline ? "在线" : "离线"}
@@ -197,58 +197,14 @@ export function MobileCapturePage() {
</div>
<div className="grid gap-2" data-testid="mobile-capture-file">
<label
htmlFor="mobile-capture-camera-file"
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
>
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-[22px] text-blue-700">photo_camera</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-zinc-900">拍照上传</span>
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">打开摄像头拍摄发票</span>
</span>
</label>
<input
id="mobile-capture-camera-file"
accept="image/*"
capture="environment"
className="sr-only"
data-testid="mobile-capture-camera-file"
onChange={(event) => {
setSelectedFiles(Array.from(event.target.files ?? []));
}}
type="file"
/>
<label
htmlFor="mobile-capture-image-file"
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
>
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-cyan-50 text-[22px] text-cyan-700">image</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-zinc-900">选择图片</span>
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">JPG、PNG、WEBP</span>
</span>
</label>
<input
id="mobile-capture-image-file"
accept="image/jpeg,image/png,image/webp"
className="sr-only"
data-testid="mobile-capture-image-file"
multiple
onChange={(event) => {
setSelectedFiles(Array.from(event.target.files ?? []));
}}
type="file"
/>
<label
htmlFor="mobile-capture-pdf-file"
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
>
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-[22px] text-emerald-700">picture_as_pdf</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-zinc-900">选择 PDF</span>
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票和扫描件 PDF</span>
<span className="block text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票或扫描件 PDF</span>
</span>
</label>
<input
+1 -1
查看文件
@@ -224,7 +224,7 @@ export function MobilePortalPage() {
? {
key: "capture",
label: "传票据",
meta: "拍照或选文件",
meta: "上传 PDF",
icon: "receipt_long",
to: "/mobile/capture",
tone: "primary",
@@ -164,7 +164,6 @@ export function MobileReimbursementDetailPage() {
const queryClient = useQueryClient();
const { me, permissions, accessToken } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const fileDocumentInputRef = useRef<HTMLInputElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
@@ -563,9 +562,6 @@ export function MobileReimbursementDetailPage() {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
if (fileDocumentInputRef.current) {
fileDocumentInputRef.current.value = "";
}
}
const messages: string[] = [];
@@ -697,9 +693,6 @@ export function MobileReimbursementDetailPage() {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
if (fileDocumentInputRef.current) {
fileDocumentInputRef.current.value = "";
}
};
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
@@ -763,7 +756,16 @@ export function MobileReimbursementDetailPage() {
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
return (
<PageScaffold className={cn("space-y-4", showStickyActions ? "pb-28" : "pb-6")} data-testid="mobile-reimbursement-detail">
<PageScaffold
className={cn(
"space-y-4",
showStickyActions ? "mobile-fixed-action-page" : "",
showStickyActions
? "pb-[calc(var(--mobile-sticky-action-height,76px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "pb-6",
)}
data-testid="mobile-reimbursement-detail"
>
<header className="flex items-center gap-3" data-testid="mobile-reimbursement-detail">
<Button asChild size="sm" variant="secondary">
<Link to="/mobile/reimbursements">返回</Link>
@@ -1088,12 +1090,13 @@ export function MobileReimbursementDetailPage() {
{showStickyActions ? (
<div
className="sticky bottom-0 z-20 -mx-4 border-t border-zinc-200 bg-white/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
className="fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-reimbursement-sticky-actions"
>
<div className={cn("grid gap-2", canOpenInvoiceDialog && showSubmitAction ? "grid-cols-2" : "grid-cols-1")}>
{canOpenInvoiceDialog ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
data-testid="mobile-reimbursement-sticky-invoice-add"
variant={showSubmitAction ? "secondary" : "default"}
type="button"
@@ -1104,6 +1107,7 @@ export function MobileReimbursementDetailPage() {
) : null}
{showSubmitAction ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
data-testid={TEST_IDS.reimbursement.submit}
disabled={submitMutation.isPending}
type="button"
@@ -1118,9 +1122,10 @@ export function MobileReimbursementDetailPage() {
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
<DialogContent
className="max-h-[86dvh] overflow-y-auto p-4"
className="overflow-y-auto p-4"
data-testid={TEST_IDS.reimbursement.invoiceDialog}
onOpenAutoFocus={(event) => event.preventDefault()}
style={{ maxHeight: "min(86vh, calc(var(--mobile-viewport-height, 100vh) - 3rem))" }}
>
<DialogHeader className="text-left">
<DialogTitle>添加发票</DialogTitle>
@@ -1165,42 +1170,23 @@ export function MobileReimbursementDetailPage() {
{invoicePanel === "upload" ? (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-2">
<label
htmlFor="mobile-reimbursement-invoice-upload-image"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
>
<span className="material-symbols-outlined text-[28px] text-zinc-700">photo_camera</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选图片</span>
<span className="mt-1 text-xs text-zinc-500">适合手机拍发票</span>
</label>
<label
htmlFor="mobile-reimbursement-invoice-upload-document"
htmlFor="mobile-reimbursement-invoice-upload-pdf"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
>
<span className="material-symbols-outlined text-[28px] text-zinc-700">picture_as_pdf</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF/文件</span>
<span className="mt-1 text-xs text-zinc-500">适合电子发票</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
<span className="mt-1 text-xs text-zinc-500">仅支持 PDF,减少拍照模糊和错传</span>
</label>
</div>
<input
ref={fileInputRef}
id="mobile-reimbursement-invoice-upload-image"
id="mobile-reimbursement-invoice-upload-pdf"
className="sr-only"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="image/*"
capture="environment"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
<input
ref={fileDocumentInputRef}
id="mobile-reimbursement-invoice-upload-document"
className="sr-only"
data-testid="reimbursement-invoice-upload-document"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
accept="application/pdf"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
@@ -1251,7 +1237,10 @@ export function MobileReimbursementDetailPage() {
</Button>
</div>
<div className="max-h-[44dvh] space-y-2 overflow-y-auto pr-1">
<div
className="space-y-2 overflow-y-auto pr-1"
style={{ maxHeight: "min(44vh, calc(var(--mobile-viewport-height, 100vh) * 0.44))" }}
>
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{invoiceLibraryError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
+2 -2
查看文件
@@ -1606,7 +1606,7 @@ export function ReimbursementDetailPage() {
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
<p className="text-xs text-zinc-500">支持 PDF 或图片,可多选。</p>
<p className="text-xs text-zinc-500">仅支持 PDF 发票,可多选。</p>
</div>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
@@ -1625,7 +1625,7 @@ export function ReimbursementDetailPage() {
className="hidden"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
accept="application/pdf"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
@@ -62,7 +62,7 @@ export function InvoiceUploadCard({
<input
id="file-upload"
className="hidden"
accept="application/pdf,image/jpeg,image/png,image/webp"
accept="application/pdf"
data-testid="invoice-upload-file"
multiple
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
@@ -90,6 +90,7 @@ export function InvoiceUploadCard({
{selectedFiles.length > 4 ? <div>另有 {selectedFiles.length - 4} 个文件</div> : null}
</div>
) : null}
<p className="text-xs text-zinc-500">发票上传仅支持 PDF;附件材料可在对应业务单据中上传图片或其他证明文件。</p>
</div>
);
}
+16
查看文件
@@ -6,3 +6,19 @@
min-width: 44px;
}
:root {
--mobile-viewport-height: 100vh;
--mobile-bottom-nav-height: 64px;
--mobile-sticky-action-height: 76px;
}
.page-scaffold.mobile-fixed-action-page {
animation: none;
transform: none;
}
@supports (height: 100dvh) {
:root {
--mobile-viewport-height: 100dvh;
}
}
+6 -6
查看文件
@@ -34,11 +34,11 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
await expect(page.getByRole("dialog", { name: "添加发票" })).toBeVisible();
const marker = `MOCK_R73_SUCCESS_${Date.now()}`;
const invoiceFileName = `r73-success-${Date.now()}.png`;
const invoiceFileName = `r73-success-${Date.now()}.pdf`;
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({
name: invoiceFileName,
mimeType: "image/png",
buffer: Buffer.from(marker, "utf8"),
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
});
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded();
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click();
@@ -62,9 +62,9 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByTestId("invoice-upload-file").setInputFiles({
name: `r73-missing-${Date.now()}.png`,
mimeType: "image/png",
buffer: Buffer.from("MOCK_OCR_MISSING:invoiceCode,sellerName", "utf8"),
name: `r73-missing-${Date.now()}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nMOCK_OCR_MISSING:invoiceCode,sellerName\n%%EOF\n", "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
+3 -3
查看文件
@@ -30,15 +30,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 2) employee: upload invoice and wait OCR
await (await openNavLink(page, "票据中心")).click();
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const invoiceFileName = `e2e-${Date.now()}.png`;
const invoiceFileName = `e2e-${Date.now()}.pdf`;
const uploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName,
mimeType: "image/png",
buffer: Buffer.from(marker, "utf8"),
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
@@ -19,11 +19,12 @@ function resolveInvoiceFiles() {
const batchFiles = process.env.PLAYWRIGHT_OCR_LIVE_FILES?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) ?? [];
const batchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() ?? "";
if (directFile) {
if (directFile && /\.pdf$/i.test(directFile)) {
files.add(path.resolve(directFile));
}
for (const file of batchFiles) {
if (!/\.pdf$/i.test(file)) continue;
files.add(path.resolve(file));
}
@@ -31,7 +32,7 @@ function resolveInvoiceFiles() {
for (const entry of readdirSync(batchDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
const fullPath = path.join(batchDir, entry);
if (!statSync(fullPath).isFile()) continue;
if (!/\.(pdf|png|jpg|jpeg|webp)$/i.test(entry)) continue;
if (!/\.pdf$/i.test(entry)) continue;
files.add(path.resolve(fullPath));
}
}
@@ -76,7 +77,7 @@ test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
const invoiceFiles = resolveInvoiceFiles();
test.skip(!invoiceFiles.length, "需要提供 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
test.skip(!invoiceFiles.length, "需要提供 PDF 格式的 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
const results: BatchResult[] = [];
const liveOcrUser = resolveLiveOcrUser(EMPLOYEE);
@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { test, expect } from "../helpers/fixtures";
import { EMPLOYEE, login } from "../helpers/auth";
@@ -9,7 +10,7 @@ test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
const invoiceFilePath = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() ?? "";
test.skip(!invoiceFilePath || !existsSync(invoiceFilePath), "需要提供存在的 PLAYWRIGHT_OCR_LIVE_FILE");
test.skip(!invoiceFilePath || !existsSync(invoiceFilePath) || path.extname(invoiceFilePath).toLowerCase() !== ".pdf", "需要提供存在的 PDF 格式 PLAYWRIGHT_OCR_LIVE_FILE");
const liveOcrUser = resolveLiveOcrUser(EMPLOYEE);
@@ -85,8 +85,10 @@ test.describe("R105 pretrial visual acceptance", () => {
await page.goto("/mobile/capture");
await expect(page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible();
const fileInput = page.getByTestId("mobile-capture-file");
const fileInput = page.getByTestId("mobile-capture-pdf-file");
await expect(fileInput).toHaveAttribute("multiple", "");
await expect(fileInput).toHaveAttribute("accept", "application/pdf");
await expect(fileInput).not.toHaveAttribute("capture", /.+/);
await expect(page.getByTestId("mobile-capture-upload")).toBeVisible();
await expectNoDesktopTableOnMobile(page);
await expectNoLegacyDesignCopy(page);
@@ -438,8 +438,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
await employee390.page.context().setOffline(true);
await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline")));
await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible();
await employee390.page.getByTestId("mobile-capture-file").setInputFiles([
{ name: "r108-mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("r108-mobile-a") },
await employee390.page.getByTestId("mobile-capture-pdf-file").setInputFiles([
{ name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
]);
await expect(employee390.page.getByText("已选择 2 个")).toBeVisible();
@@ -161,9 +161,9 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
await employee.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles([
{
name: `r109-mobile-${Date.now()}.jpg`,
mimeType: "image/jpeg",
buffer: Buffer.from(`r109-mobile-upload-${Date.now()}`),
name: `r109-mobile-${Date.now()}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\nr109-mobile-upload-${Date.now()}\n%%EOF\n`),
},
]);
await employee.page.getByTestId("reimbursement-invoice-upload-submit").click();
@@ -145,7 +145,9 @@ test.describe.serial("R110 手机和 PC 路由分流", () => {
await mobile.page.goto("/invoice-pool");
await expect(mobile.page).toHaveURL(/\/mobile\/capture$/);
await expect(mobile.page.getByTestId("mobile-capture-file")).toBeAttached();
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toBeAttached();
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", "application/pdf");
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
await expectNoPcShell(mobile.page);
await mobile.page.goto("/assets");
@@ -483,16 +483,29 @@ async function collectVisualMetrics(page: Page) {
document.querySelector("nav a[aria-current='page']");
const activeStyle = activeNav ? getComputedStyle(activeNav) : null;
const activeRgb = activeStyle ? parseRgb(activeStyle.backgroundColor) : null;
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
const stickyRect = stickyActions?.getBoundingClientRect();
const addButton = document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']");
const submitButton = document.querySelector("[data-testid='reimbursement-submit']");
const addRect = addButton?.getBoundingClientRect();
const submitRect = submitButton?.getBoundingClientRect();
const bodyText = document.body?.innerText ?? "";
return {
width: window.innerWidth,
height: window.innerHeight,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
navCount: document.querySelectorAll("[data-testid^='mobile-nav-']").length,
navHeight: navRect ? Math.round(navRect.height) : 0,
activeNavBackground: activeStyle?.backgroundColor ?? "",
activeNavAverage: activeRgb ? Math.round((activeRgb.r + activeRgb.g + activeRgb.b) / 3) : 255,
activeNavAlpha: activeRgb?.a ?? 0,
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
stickyBottomGap: stickyRect ? Math.round(window.innerHeight - stickyRect.bottom) : null,
stickyHeight: stickyRect ? Math.round(stickyRect.height) : 0,
stickyButtonsSameRow: Boolean(addRect && submitRect && Math.abs(addRect.top - submitRect.top) <= 2),
stickyButtonMinHeight: Math.min(addRect?.height ?? 0, submitRect?.height ?? 0),
activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "",
bodyText,
};
@@ -560,11 +573,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
expect(beforeDialogMetrics.tableCount).toBe(0);
expect(beforeDialogMetrics.navHeight).toBeLessThanOrEqual(68);
if (beforeDialogMetrics.activeNavAlpha > 0) {
expect(beforeDialogMetrics.activeNavAlpha).toBeGreaterThan(0.9);
expect(beforeDialogMetrics.activeNavAverage, beforeDialogMetrics.activeNavBackground).toBeLessThan(245);
}
expect(beforeDialogMetrics.navCount).toBe(0);
expect(beforeDialogMetrics.navHeight).toBe(0);
expect(beforeDialogMetrics.stickyActionsVisible).toBe(true);
expect(beforeDialogMetrics.stickyBottomGap).toBeLessThanOrEqual(2);
expect(beforeDialogMetrics.stickyButtonsSameRow).toBe(true);
expect(beforeDialogMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
await mobile.page.getByTestId("reimbursement-invoice-add").click();
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
@@ -592,20 +606,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
await invoiceDialog.getByRole("button", { name: "上传" }).click();
await expect(invoiceDialog.getByText("拍照或选图片")).toBeVisible();
await expect(invoiceDialog.getByText("选择 PDF/文件")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "image/*");
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("capture", "environment");
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).toHaveAttribute(
"accept",
/application\/pdf/,
);
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).not.toHaveAttribute(
"capture",
/.+/,
);
await expect(invoiceDialog.getByText("选择 PDF 发票")).toBeVisible();
await expect(invoiceDialog.getByText("拍照或选图片")).toHaveCount(0);
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "application/pdf");
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).not.toHaveAttribute("capture", /.+/);
await mobile.page.getByTestId("reimbursement-invoice-upload-document").setInputFiles({
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 mobile invoice"),
@@ -476,12 +476,9 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await mobile.page.goto("/mobile/capture");
await expect(mobile.page).toHaveURL(/\/mobile\/capture$/);
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toBeAttached();
await expect(mobile.page.getByTestId("mobile-capture-image-file")).toBeAttached();
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-capture-image-file")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toBeAttached();
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveAttribute("capture", "environment");
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveAttribute("accept", "image/*");
await expect(mobile.page.getByTestId("mobile-capture-image-file")).not.toHaveAttribute("capture", /.+/);
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", "application/pdf");
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({
@@ -4,8 +4,22 @@ import { expect, test, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r5-real-device-feedback-mobile-ux");
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const DEVICE_CASES = [
{
key: "iphone11-ios14",
label: "iPhone 11 iOS14",
viewport: { width: 414, height: 896 },
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN",
},
{
key: "redmi-k60",
label: "Redmi K60",
viewport: { width: 393, height: 873 },
userAgent:
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN",
},
] as const;
const employee = {
id: "11111111-1111-4111-8111-111111111111",
@@ -200,61 +214,115 @@ async function login(page: Page) {
async function collectMobileMetrics(page: Page) {
return page.evaluate(() => {
const bottomNav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav");
const bottomNavRect = bottomNav?.getBoundingClientRect();
const bottomNavStyle = bottomNav ? window.getComputedStyle(bottomNav) : null;
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
const rect = element?.getBoundingClientRect();
return {
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
};
});
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
const stickyRect = stickyActions?.getBoundingClientRect();
const stickyButtons = [
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
document.querySelector("[data-testid='reimbursement-submit']"),
].filter(Boolean).map((element) => element!.getBoundingClientRect());
return {
width: window.innerWidth,
height: window.innerHeight,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
bottomNavVisible: Boolean(bottomNav && bottomNav.getBoundingClientRect().height > 0),
bottomNavVisible: Boolean(bottomNavRect && bottomNavRect.height > 0),
bottomNavFixed: bottomNavStyle?.position === "fixed",
bottomNavBottomGap: bottomNavRect ? window.innerHeight - bottomNavRect.bottom : null,
navItems,
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
stickyBottomGap: stickyRect ? window.innerHeight - stickyRect.bottom : null,
stickyButtonsSameRow:
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
stickyButtonMinWidth: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.width)) : 0,
text: document.body?.innerText ?? "",
};
});
}
test.describe("R5 真机反馈移动交互", () => {
test.use({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
userAgent: WECOM_UA,
locale: "zh-CN",
serviceWorkers: "block",
});
for (const device of DEVICE_CASES) {
test.describe(device.label, () => {
test.use({
viewport: device.viewport,
isMobile: true,
hasTouch: true,
userAgent: device.userAgent,
locale: "zh-CN",
serviceWorkers: "block",
});
test("中文头像首字、报销底部 Tab 和详情深层页交互符合企业微信体验", async ({ page }) => {
await mockApis(page);
await login(page);
test("中文头像首字、底部 Tab 固定和详情深层页双按钮符合企业微信体验", async ({ page }) => {
await mockApis(page);
await login(page);
await page.goto("/mobile/me");
await expect(page.getByText("王", { exact: true })).toBeVisible();
await page.screenshot({ path: screenshotPath("mobile-me-avatar"), fullPage: true });
await page.goto("/mobile/me");
await expect(page.getByText("王", { exact: true })).toBeVisible();
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-me-avatar`), fullPage: true });
await page.goto("/mobile/reimbursements");
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await page.screenshot({ path: screenshotPath("mobile-reimbursements-tab"), fullPage: true });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-home-bottom-nav`), fullPage: true });
const listMetrics = await collectMobileMetrics(page);
expect(listMetrics.bottomNavVisible).toBe(true);
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
const homeMetrics = await collectMobileMetrics(page);
expect(homeMetrics.bottomNavVisible).toBe(true);
expect(homeMetrics.bottomNavFixed).toBe(true);
expect(homeMetrics.bottomNavBottomGap).not.toBeNull();
expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2);
for (const item of homeMetrics.navItems) {
expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true);
expect(item.width, `${device.label} ${item.key} nav item touch width`).toBeGreaterThanOrEqual(44);
expect(item.height, `${device.label} ${item.key} nav item touch height`).toBeGreaterThanOrEqual(44);
}
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
await expect(page.getByRole("link", { name: "返回" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toBeVisible();
await expect(page.getByTestId("reimbursement-submit")).toBeVisible();
await page.screenshot({ path: screenshotPath("mobile-reimbursement-detail-sticky"), fullPage: true });
await page.goto("/mobile/reimbursements");
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursements-tab`), fullPage: true });
const detailMetrics = await collectMobileMetrics(page);
expect(detailMetrics.bottomNavVisible).toBe(false);
expect(detailMetrics.stickyActionsVisible).toBe(true);
expect(detailMetrics.tableCount).toBe(0);
expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2);
expect(detailMetrics.text).not.toContain("发票池");
expect(detailMetrics.text).not.toContain("绑定整单");
});
const listMetrics = await collectMobileMetrics(page);
expect(listMetrics.bottomNavVisible).toBe(true);
expect(listMetrics.bottomNavFixed).toBe(true);
expect(listMetrics.bottomNavBottomGap).not.toBeNull();
expect(listMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
await expect(page.getByRole("link", { name: "返回" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toBeVisible();
await expect(page.getByTestId("reimbursement-submit")).toBeVisible();
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursement-detail-sticky`), fullPage: true });
const detailMetrics = await collectMobileMetrics(page);
expect(detailMetrics.bottomNavVisible).toBe(false);
expect(detailMetrics.stickyActionsVisible).toBe(true);
expect(detailMetrics.stickyBottomGap).not.toBeNull();
expect(detailMetrics.stickyBottomGap!).toBeLessThanOrEqual(2);
expect(detailMetrics.stickyButtonsSameRow).toBe(true);
expect(detailMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
expect(detailMetrics.stickyButtonMinWidth).toBeGreaterThanOrEqual(120);
expect(detailMetrics.tableCount).toBe(0);
expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2);
expect(detailMetrics.text).not.toContain("发票池");
expect(detailMetrics.text).not.toContain("绑定整单");
});
});
}
});
@@ -183,9 +183,9 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom
await api.post("/api/invoice-pool/upload", {
multipart: {
file: {
name: `notify-${label}.png`,
mimeType: "image/png",
buffer: Buffer.from(`PW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}`, "utf8"),
name: `notify-${label}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\nPW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}\n%%EOF\n`, "utf8"),
},
ocr_type: "general",
},
@@ -342,9 +342,9 @@ test.describe("R92 手机端票据与报销可用性", () => {
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 page.getByTestId("mobile-capture-pdf-file").setInputFiles([
{ name: "mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
{ name: "mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
]);
await expect(page.getByText("已选择 2 个")).toBeVisible();
await attachScreenshot(page, testInfo, "r97-mobile-capture-multi-upload.png");
@@ -88,9 +88,9 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByTestId("invoice-upload-file").setInputFiles({
name: `r78-${Date.now()}.png`,
mimeType: "image/png",
buffer: Buffer.from(marker, "utf8"),
name: `r78-${Date.now()}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
@@ -31,9 +31,9 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByTestId("invoice-upload-file").setInputFiles({
name: `progress-${Date.now()}.png`,
mimeType: "image/png",
buffer: Buffer.from(marker, "utf8"),
name: `progress-${Date.now()}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
+3 -3
查看文件
@@ -52,15 +52,15 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
await expect(orderCard).not.toContainText("标记已支付");
await page.goto("/invoice-pool");
const invoiceFileName = `travel-payment-${Date.now()}.png`;
const invoiceFileName = `travel-payment-${Date.now()}.pdf`;
const uploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName,
mimeType: "image/png",
buffer: Buffer.from(`TRAVEL_PAYMENT_${Date.now()}`, "utf8"),
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\nTRAVEL_PAYMENT_${Date.now()}\n%%EOF\n`, "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
+1 -1
查看文件
@@ -92,7 +92,7 @@ export default defineConfig({
manifest: {
name: "hyb-oa 移动工作台",
short_name: "hyb-oa",
description: "海音达工作台移动端入口,支持离线拍照上传与移动审批。",
description: "海音达工作台移动端入口,支持离线 PDF 发票上传与移动审批。",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
+3 -3
查看文件
@@ -5,11 +5,11 @@
- **关联 Issue**: S2
## 背景
当前系统为响应式 Web。业务关注移动场景下的“离线拍照、弱网上传、回填状态可追踪”。
当前系统为响应式 Web。业务关注移动场景下的“离线 PDF 发票上传、弱网上传、回填状态可追踪”。
## 决策目标
1. 选择下一阶段移动路线。
2. 验证离线拍照与上传可靠性。
2. 验证离线 PDF 发票上传可靠性。
3. 输出成本、风险、回滚方案。
## 备选方案
@@ -37,7 +37,7 @@
## PoC 结果
- PWA 原型验证:
- 离线缓存拍照元数据可行;
- 离线缓存 PDF 发票元数据可行;
- 网络恢复后自动上传成功率高;
- 异常重试与失败提示可在现有前端框架中快速实现。
- RN 原型验证:
+1 -1
查看文件
@@ -126,7 +126,7 @@
| G6 | 回归脚本与文档收口 | §16(回归工程化) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-feature-flags.sh | none |
| H1 | 安全与压测门槛 | §16(非功能治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/security-baseline.sh + benchmark-dashboard.sh | none |
| M1 | PWA基础设施 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | client:typecheck/build | none |
| M2 | 移动拍照与离线上传队列 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | iOS前台恢复重放 |
| M2 | 移动 PDF 发票与离线上传队列 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | iOS前台恢复重放 |
| M3 | 移动审批快捷处理 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | none |
| M4 | P12验收与收口 | §16(状态治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | docs/mobile/pwa-regression-checklist.md | none |
+3 -3
查看文件
@@ -13,9 +13,9 @@
期望:离线壳可访问,不应白屏崩溃。
## 场景 B:移动拍照离线上传
1. 访问 `移动拍照` 页面。
2. 断网,选择图片文件,点击“立即上传”。
## 场景 B:移动 PDF 发票离线上传
1. 访问 `移动 PDF 发票上传` 页面。
2. 断网,选择 PDF 文件,点击“立即上传”。
3. 确认提示“已加入待上传队列”。
4. 恢复网络,点击“同步队列”或等待自动重放。
5. 进入票据中心确认文件已上传。
+3 -2
查看文件
@@ -87,8 +87,9 @@ OA 页面应参考公司主页 `www.hyinda.com` 的视觉气质,但不能做
- 草稿允许删除;审批中使用撤回;已通过和已支付不提供删除。
- 报销明细存在且只有一条时,上传发票默认绑定该明细。
- 多条明细时优先按金额匹配;无法匹配时自动生成新明细,不向普通用户暴露“绑定整单”。
- 发票上传支持批量,OCR 显示明确状态和可重试入口,不长期停留在“上传中/处理中”。
- PC 端适合完整明细和对账;手机端适合拍照上传、补充说明、继续编辑草稿和查状态。
- 发票上传支持批量 PDF,OCR 显示明确状态和可重试入口,不长期停留在“上传中/处理中”。
- PC 端适合完整明细和对账;手机端适合上传 PDF 发票、补充说明、继续编辑草稿和查状态。
- 报销详情等深层页的底部主操作在 iPhone 11 等小屏上仍保持同行双按钮,不因适配降级为单列。
## 请假、票据、采购、差旅规则
+3 -3
查看文件
@@ -33,7 +33,7 @@
## 上传与 OCR
- PDF 和图片均可上传;若走腾讯 OCR,PDF 可先转图片再识别,具体实现以当前 OCR provider 能力为准。
- 发票/OCR 上传只允许 PDF 发票文件;图片、拍照直传和 HTML `capture` 入口不作为正式发票上传方式。
- 上传后页面必须显示明确状态,不应长期停留在“上传中”。
- OCR 完成后,添加发票弹窗应自动刷新并尝试添加可用发票。
- OCR 失败、重复、已使用、他人已使用都要显示可理解原因。
@@ -103,10 +103,10 @@ PC 端:
手机/企业微信端:
- 适合新建、继续编辑草稿、拍照/上传发票、补充附件、提交审批、查状态。
- 适合新建、继续编辑草稿、上传 PDF 发票、补充附件、提交审批、查状态。
- 不显示桌面表格。
- 添加发票不默认聚焦搜索框,不自动弹键盘。
- 底部操作区紧凑,选中态使用品牌实心色。
- 底部操作区紧凑,选中态使用品牌实心色;核心操作在 iPhone 11 等小屏上仍保持同行双按钮,不降级为单列。
## 自动化验收
+1 -1
查看文件
@@ -9,7 +9,7 @@
"项目、合同台账与涉密边界","must_cover","/projects|/projects/:projectId|/contracts-registry","manager|admin|project roles","PROJECT_DOMAIN_ENABLED|PROJECT_LINKING_ENABLED|PROJECT_COMMUNICATION_LOG_ENABLED|PROJECT_CLOSURE_CHECKLIST_ENABLED|CONFIDENTIAL_DOC_REFS_ENABLED","project_domain_v1|project_linking_v1|project_communication_log_v1|project_closure_checklist_v1|confidential_doc_refs_v1","partial","docs/trialrun/manual-trialrun-report.md|client/tests/e2e/project-workbench.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/manual/r70-trialrun-assist.spec.ts","项目工作台沟通回填、收口面板、Portal 摘要、涉密搜索边界已自动化;经理人工确认项目入口可理解。","合同台账页面本轮只验证入口,没有当前 trialrun 下的浏览/编辑证据;真实项目创建与成员协同未重跑。","A4 可补项目详情与合同台账只读 smoke;重交互保持后置。"
"采购","enabled_admin","/purchase-requests","employee|manager|finance","none","none","partial_done","artifacts/r69/acceptance-report.md|client/tests/e2e/procurement-flow.spec.ts|issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv","员工提单、经理审批、财务归档链路已自动化通过,且事务超时问题已收口。","未进入 R70 人工试运行,当前 trialrun DB 下没有业务体验反馈。","若采购进入试运行范围,先补一次人工 happy path;否则保留自动化证据即可。"
"资产与盘点","enabled_admin","/assets|/inventory-checks|/mobile/assets/scan","employee|asset admin","none","none","partial_done","artifacts/r69/acceptance-report.md|client/tests/e2e/assets-flow.spec.ts|issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv","资产建卡、领用归还、调拨、盘点主链路已自动化通过。","未进入 R70 人工试运行;移动扫描真实设备与摄像头场景未验证。","保持自动化证据;真实移动扫描转 A5 或独立移动专项。"
"差旅与移动审批","enabled_admin","/travel-requests|/mobile/approvals|/mobile/capture","employee|manager|finance","WECOM_ENTRY_ENABLED","wecom_entry_v1","partial_done","artifacts/r69/acceptance-report.md|client/tests/e2e/travel-payment-flow.spec.ts|client/tests/e2e/travel-reconciliation-flow.spec.ts|client/tests/e2e/mobile-approvals-offline.spec.ts|docs/trialrun/manual-trialrun-report.md","差旅申请、审批、支付、绑票、对账批次,以及移动审批 deep link 离线重放都有自动化证据;R70 人工仅确认移动入口可理解。","真实 IM deep link、真实移动设备拍照上传、多人并发审批未验证。","保留自动化为主;真实设备和 IM 深链并入 A5。"
"差旅与移动审批","enabled_admin","/travel-requests|/mobile/approvals|/mobile/capture","employee|manager|finance","WECOM_ENTRY_ENABLED","wecom_entry_v1","partial_done","artifacts/r69/acceptance-report.md|client/tests/e2e/travel-payment-flow.spec.ts|client/tests/e2e/travel-reconciliation-flow.spec.ts|client/tests/e2e/mobile-approvals-offline.spec.ts|docs/trialrun/manual-trialrun-report.md","差旅申请、审批、支付、绑票、对账批次,以及移动审批 deep link 离线重放都有自动化证据;R70 人工仅确认移动入口可理解。","真实 IM deep link、真实移动设备 PDF 上传、多人并发审批未验证。","保留自动化为主;真实设备和 IM 深链并入 A5。"
"预算、支付批次与 AP 运营","optional","/budgets|/payment-batches|/ap-operations","finance|admin","BUDGET_PRECHECK_ENABLED|PAYMENT_BATCH_ENABLED|ERP_SYNC_ENABLED","budget_precheck_v1|payment_batch_v1|erp_sync","partial_done","issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv|issues/2026-04-06_r52-ap-operations.csv|issues/2026-04-29_10-14-27-r78-reimbursement-payment-tail.csv|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts|server/scripts/regression-payment-batches.sh","支付批次 maker-checker、AP 待付款快捷复核、finance 执行报销付款批次,以及 reimbursement paid 后 ERP preview pending outbox 已在本地 focused regression 跑通;历史 budget/AP 视图与 review 证据仍保留。","预算预检页面级联动、高风险两轮复核、大额批次与非报销实体付款未在本轮 focused local 回归中重跑。","若财务域继续扩大试运行,再补 budget precheck 与高风险二次复核专项;当前 payment batch/AP happy path 已具备本地回归证据。"
"ERP 集成与切换控制台","external","/system/integrations/erp|/system/cutover","integration admin|system.settings","ERP_SYNC_ENABLED|CUTOVER_CONSOLE_ENABLED","erp_sync|cutover_console_v1","partial_external","issues/2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv|issues/2026-03-21_11-10-00-p29-replacement-readiness-refresh.csv|client/tests/e2e/live-smoke.spec.ts","ERP 管理页、mock/http 回归和只读 live smoke 有历史证据。","当前本地 trialrun 没有真实 ERP 出入站或 cutover 操作验证。","仅在外部环境专项下执行,不进入默认 trialrun gate。"
"系统治理、功能开关与审计","enabled_admin","/system/workspace|/system/settings|/system/feature-flags|/system/workflow-definitions|/audit-logs|/system/notification-channels","admin","WORKFLOW_DEFINITION_ADMIN_ENABLED","workflow_definition_admin_v1","partial_done","docs/trialrun/manual-trialrun-report.md|client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/live-smoke.spec.ts|server/src/scripts/smoke-trialrun-runtime.js|client/tests/e2e/r75-automation-gap-fill.spec.ts|issues/2026-05-05_20-01-18-r90-r91-trialrun-closeout.csv","工作区设置、系统设置、功能开关、审计日志打开与导航边界有自动化和人工证据;R74 本地门禁已检查 workspace/flag/navigation 基线,R75 已补功能开关备注保存并恢复写路径。R80 已把 `wecom_entry_v1` 从 `force_off` 调整为 `inherit`,R90 已把旧公网诊断从当前事实中剥离。","流程定义、通知渠道、工作区其他写操作和审计留痕写入效果仍未在当前 trialrun 做完整专项;公网 `announcements_v1` / `global_search_v1` 仍需在 live smoke 中重新核对。","保留 partial_done;R90/R91 先收口公网 feature flag 与 live smoke,再补通知渠道与流程定义写路径。"
1 Domain Tier Routes Primary_Roles Env_Gates Feature_Flags Coverage_Status Evidence Validated_Flows Gaps Next_Action
9 项目、合同台账与涉密边界 must_cover /projects|/projects/:projectId|/contracts-registry manager|admin|project roles PROJECT_DOMAIN_ENABLED|PROJECT_LINKING_ENABLED|PROJECT_COMMUNICATION_LOG_ENABLED|PROJECT_CLOSURE_CHECKLIST_ENABLED|CONFIDENTIAL_DOC_REFS_ENABLED project_domain_v1|project_linking_v1|project_communication_log_v1|project_closure_checklist_v1|confidential_doc_refs_v1 partial docs/trialrun/manual-trialrun-report.md|client/tests/e2e/project-workbench.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/manual/r70-trialrun-assist.spec.ts 项目工作台沟通回填、收口面板、Portal 摘要、涉密搜索边界已自动化;经理人工确认项目入口可理解。 合同台账页面本轮只验证入口,没有当前 trialrun 下的浏览/编辑证据;真实项目创建与成员协同未重跑。 A4 可补项目详情与合同台账只读 smoke;重交互保持后置。
10 采购 enabled_admin /purchase-requests employee|manager|finance none none partial_done artifacts/r69/acceptance-report.md|client/tests/e2e/procurement-flow.spec.ts|issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv 员工提单、经理审批、财务归档链路已自动化通过,且事务超时问题已收口。 未进入 R70 人工试运行,当前 trialrun DB 下没有业务体验反馈。 若采购进入试运行范围,先补一次人工 happy path;否则保留自动化证据即可。
11 资产与盘点 enabled_admin /assets|/inventory-checks|/mobile/assets/scan employee|asset admin none none partial_done artifacts/r69/acceptance-report.md|client/tests/e2e/assets-flow.spec.ts|issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv 资产建卡、领用归还、调拨、盘点主链路已自动化通过。 未进入 R70 人工试运行;移动扫描真实设备与摄像头场景未验证。 保持自动化证据;真实移动扫描转 A5 或独立移动专项。
12 差旅与移动审批 enabled_admin /travel-requests|/mobile/approvals|/mobile/capture employee|manager|finance WECOM_ENTRY_ENABLED wecom_entry_v1 partial_done artifacts/r69/acceptance-report.md|client/tests/e2e/travel-payment-flow.spec.ts|client/tests/e2e/travel-reconciliation-flow.spec.ts|client/tests/e2e/mobile-approvals-offline.spec.ts|docs/trialrun/manual-trialrun-report.md 差旅申请、审批、支付、绑票、对账批次,以及移动审批 deep link 离线重放都有自动化证据;R70 人工仅确认移动入口可理解。 真实 IM deep link、真实移动设备拍照上传、多人并发审批未验证。 真实 IM deep link、真实移动设备 PDF 上传、多人并发审批未验证。 保留自动化为主;真实设备和 IM 深链并入 A5。
13 预算、支付批次与 AP 运营 optional /budgets|/payment-batches|/ap-operations finance|admin BUDGET_PRECHECK_ENABLED|PAYMENT_BATCH_ENABLED|ERP_SYNC_ENABLED budget_precheck_v1|payment_batch_v1|erp_sync partial_done issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv|issues/2026-04-06_r52-ap-operations.csv|issues/2026-04-29_10-14-27-r78-reimbursement-payment-tail.csv|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts|server/scripts/regression-payment-batches.sh 支付批次 maker-checker、AP 待付款快捷复核、finance 执行报销付款批次,以及 reimbursement paid 后 ERP preview pending outbox 已在本地 focused regression 跑通;历史 budget/AP 视图与 review 证据仍保留。 预算预检页面级联动、高风险两轮复核、大额批次与非报销实体付款未在本轮 focused local 回归中重跑。 若财务域继续扩大试运行,再补 budget precheck 与高风险二次复核专项;当前 payment batch/AP happy path 已具备本地回归证据。
14 ERP 集成与切换控制台 external /system/integrations/erp|/system/cutover integration admin|system.settings ERP_SYNC_ENABLED|CUTOVER_CONSOLE_ENABLED erp_sync|cutover_console_v1 partial_external issues/2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv|issues/2026-03-21_11-10-00-p29-replacement-readiness-refresh.csv|client/tests/e2e/live-smoke.spec.ts ERP 管理页、mock/http 回归和只读 live smoke 有历史证据。 当前本地 trialrun 没有真实 ERP 出入站或 cutover 操作验证。 仅在外部环境专项下执行,不进入默认 trialrun gate。
15 系统治理、功能开关与审计 enabled_admin /system/workspace|/system/settings|/system/feature-flags|/system/workflow-definitions|/audit-logs|/system/notification-channels admin WORKFLOW_DEFINITION_ADMIN_ENABLED workflow_definition_admin_v1 partial_done docs/trialrun/manual-trialrun-report.md|client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/live-smoke.spec.ts|server/src/scripts/smoke-trialrun-runtime.js|client/tests/e2e/r75-automation-gap-fill.spec.ts|issues/2026-05-05_20-01-18-r90-r91-trialrun-closeout.csv 工作区设置、系统设置、功能开关、审计日志打开与导航边界有自动化和人工证据;R74 本地门禁已检查 workspace/flag/navigation 基线,R75 已补功能开关备注保存并恢复写路径。R80 已把 `wecom_entry_v1` 从 `force_off` 调整为 `inherit`,R90 已把旧公网诊断从当前事实中剥离。 流程定义、通知渠道、工作区其他写操作和审计留痕写入效果仍未在当前 trialrun 做完整专项;公网 `announcements_v1` / `global_search_v1` 仍需在 live smoke 中重新核对。 保留 partial_done;R90/R91 先收口公网 feature flag 与 live smoke,再补通知渠道与流程定义写路径。
+1 -1
查看文件
@@ -38,7 +38,7 @@
| 领域 | 当前合理点 | 主要差距 | 优化优先级 |
| --- | --- | --- | --- |
| 企业微信入口 | 已有 `/mobile`、OAuth config、移动底部导航和公网入口配置 | 真机企业微信 OAuth + 真实账号回跳证据不足;仍需防 PC 页面误入 | P0 |
| 报销与票据 | 已支持报销、发票上传、OCR、绑定、解绑、查重、付款批次 | OCR 真实耗时、状态变化和失败提示需要 live 证据;票据入口需继续区分拍照/图片/PDF | P0 |
| 报销与票据 | 已支持报销、PDF 发票上传、OCR、绑定、解绑、查重、付款批次 | OCR 真实耗时、状态变化和失败提示需要 live 证据;票据入口需确认 PDF-only 在企业微信文件选择器中可用 | P0 |
| 费用规则 | 已有费用类型、审批规则、预算预检、支付批次 | 普通用户流程还需更强的提交前解释和异常引导 | P1 |
| 审批 | PC/移动审批和离线队列已有自动化 | 多人并发、代理审批、真实企业微信 deep link 还缺 live 证据 | P1 |
| 电子档案 | 发票、附件、审批、付款、ERP preview 已有链路 | 财务视角归档检索、合同/发票/付款跨域追溯仍偏分散 | P1 |
+2 -2
查看文件
@@ -13,8 +13,8 @@
## 真实 OCR 复核
- `PLAYWRIGHT_OCR_LIVE_FILE`:单张真实测试发票 PDF/JPG/PNG/WEBP。
- 或 `PLAYWRIGHT_OCR_LIVE_DIR`:批量票样目录。
- `PLAYWRIGHT_OCR_LIVE_FILE`:单张真实测试发票 PDF。
- 或 `PLAYWRIGHT_OCR_LIVE_DIR`:批量 PDF 票样目录。
- 可选 `PLAYWRIGHT_OCR_LIVE_IDENTIFIER` / `PLAYWRIGHT_OCR_LIVE_PASSWORD`:专用 OCR 测试员工账号;未提供时复用 `LIVE_SMOKE_EMPLOYEE_*`。
- 可选 `PLAYWRIGHT_OCR_ALLOW_DUPLICATE=1`:允许重复样本被识别为重复,用于反复测试同一张票。
@@ -2,7 +2,7 @@
**文档状态**:当前操作手册,用于真实手机、企业微信和移动浏览器人工验收。
适用范围:使用真实手机访问 `https://oa.hyinda.com`,重点验证企业微信工作台、手机浏览器、拍照/PDF 上传、真实 OCR 状态反馈和移动核心流程。
适用范围:使用真实手机访问 `https://oa.hyinda.com`,重点验证企业微信工作台、手机浏览器、PDF 发票上传、真实 OCR 状态反馈和移动核心流程。
## 测试前信息
@@ -15,7 +15,7 @@
- 企业微信版本:
- 浏览器:
- 网络:公司 Wi-Fi / 4G / 5G / 其他
- 测试发票样本:PDF / JPG / PNG / WEBP
- 测试发票样本:PDF
## 企业微信入口
@@ -46,9 +46,9 @@
## 票据上传
- 报销详情“添加发票”上传页应同时提供“拍照或选图片”和“选择 PDF/文件”两个入口。
- “拍照或选图片”入口可打开摄像头或相册,支持 JPG、PNG、WEBP。
- “选择 PDF/文件”入口不应直接打开摄像头,应允许选择 PDF 文件;在企业微信里重点记录是否能进入系统文件选择器。
- 报销详情“添加发票”上传页只提供 PDF 发票上传入口。
- 发票/OCR 上传不提供拍照、图片或 HTML `capture` 直拍入口;附件材料仍可按业务需要上传图片或 PDF。
- PDF 上传入口不应直接打开摄像头,应允许选择 PDF 文件;在企业微信里重点记录是否能进入系统文件选择器。
- 上传后显示当前文件名、上传进度或失败原因。
- 网络中断或失败时有明确提示,可重试。
@@ -69,7 +69,7 @@
| 验收项 | 账号角色 | 直属上级 | 提交人 | 期望审批人 | 结果 | 截图或录屏位置 |
| --- | --- | --- | --- | --- | --- | --- |
| 普通低/中额单据 | `SMOKE002` / 测试员工 | `SMOKE101` | `SMOKE002` | `SMOKE101` | | |
| 普通低/中额单据 | `SMOKE002` / 测试员工 | `SMOKE101` | `SMOKE002` | `SMOKE101` | 通过:`oa_smoke_01 -> SMOKE002` 提交,`oa_smoke_02 -> SMOKE101` 审批 | 用户确认;截图/录屏不提交仓库 |
| 高额/高风险/例外单据 | `SMOKE002` / 测试员工 | `SMOKE101` | `SMOKE002` | `SMOKE101 -> SMOKE201 -> SMOKE302/SMOKE301`,以现有金额和财务规则为准 | | |
| 主管本人申请 | `SMOKE101` / 测试主管 | `SMOKE201` | `SMOKE101` | `SMOKE201` | | |
| 公司负责人本人申请 | `SMOKE201` / 测试负责人 | 无直属上级 | `SMOKE201` | 阻断或非本人兜底审批人 | | |
@@ -83,6 +83,8 @@
- `SMOKE001` 仅凭 `admin` 审批业务待办被拒绝,HTTP `403/PERM_002`。
- 小额报销经 `SMOKE101` 后进入 `finance` role;大额报销进入 `SMOKE101 -> ceo -> finance_mgr`,由 `SMOKE301/SMOKE302` 测试角色验证。
- 企业微信原生工作台、OAuth 绑定、通知 deeplink 和真机截图/录屏仍待使用企业微信测试成员完成;未完成前 R5/O3 不标 `DONE`。
- `2026-05-10` 真机补充:iPhone 11(iOS 14)与 Redmi K60(MIUI)企业微信最新版下,`oa_smoke_01 -> SMOKE002`、`oa_smoke_02 -> SMOKE101` 绑定、工作台进入、免登录、提交和审批详情/结果均通过。
- `2026-05-10` 阻断:真机发现移动底部导航和报销详情深层页在不同分辨率下存在底部区域不可见或控件挤压;发票上传正式口径改为 PDF-only。本地修复已完成,部署与真机复测完成前,S5/O3/R5 不标 `DONE`。
- 经理账号可在移动端看到审批待办。
- 审批详情可查看申请摘要、附件、发票、审批记录。
@@ -107,5 +109,5 @@
- 企业微信工作台和手机浏览器均可进入。
- 移动端不出现 PC 页面壳、桌面表格、横向溢出。
- 报销、票据、审批、我的、通知主流程可完成。
- 发票上传和真实 OCR 有明确状态变化和终态反馈。
- PDF 发票上传和真实 OCR 有明确状态变化和终态反馈。
- 已知问题文案和原始码值不再出现。
+1 -1
查看文件
@@ -47,7 +47,7 @@
| [试运行与自动化验证 Runbook](./trialrun-runbook.md) | 本地、公网、企业微信试运行操作 | 涉及公网或企业微信真机时必须看 |
| [发布试运行准备清单](./trialrun/release-readiness-checklist.md) | 发布候选证据、权限直连审计、部署预检、live smoke 和小范围试运行顺序 | 从本地可回归进入公网/真机验收前必须看 |
| [公网 live smoke 与真实 OCR 准备清单](./trialrun/live-smoke-readiness.md) | 公网、真实 OCR、企业微信入口复核准备 | 只有部署后才能给出真机验收结论 |
| [真机公网测试清单](./trialrun/real-device-public-checklist.md) | 手机、企业微信、拍照/PDF 上传人工验证 | 用于上线前人工验收 |
| [真机公网测试清单](./trialrun/real-device-public-checklist.md) | 手机、企业微信、PDF 发票上传人工验证 | 用于上线前人工验收 |
## 实现状态与证据
+1 -1
查看文件
@@ -176,7 +176,7 @@ OA 是公司唯一正式审批中心。以下审批必须在 OA 内提交、审
### 6.4 验收
- 手机拍照或上传发票后,能完成 OCR、查重、绑定、提交。
- 手机上上传 PDF 发票后,能完成 OCR、查重、绑定、提交。
- 报销审批通过后,财务能付款标记并导出。
- 重复发票、OCR 失败、附件缺失、金额不一致等异常有明确提示。
+3 -3
查看文件
@@ -46,7 +46,7 @@
| 入口 | 适用场景 | 要求 |
| --- | --- | --- |
| PC 报销列表/详情 | 明细编辑、批量查看、财务复核、导出 | 可查看完整字段、审批历史、发票和附件 |
| 手机报销页 | 拍照上传、继续草稿、补充附件、提交审批、查状态 | 不使用桌面宽表,操作路径短 |
| 手机报销页 | 上传 PDF 发票、继续草稿、补充附件、提交审批、查状态 | 不使用桌面宽表,操作路径短 |
| 企业微信自建应用 | 手机工作台入口和消息 deeplink | 默认进入 OA 移动端,审批动作仍在 OA 内完成 |
| 票据或发票添加弹窗 | 从报销草稿中添加发票 | 普通员工只看到“添加发票”,不暴露“发票池”等内部概念 |
| 财务导出页 | 财务按期间、人员、项目、状态导出 | 导出前必须校验财务权限 |
@@ -225,7 +225,7 @@
附件用于补充业务事实,包括支付截图、订单、合同、聊天记录、情况说明、验收材料等。
- 支持 PDF、JPG、PNG、WEBP。
- 发票/OCR 上传仅支持 PDF;附件材料可继续支持 PDF、JPG、PNG、WEBP。
- 支持多文件上传。
- 不参与 OCR。
- 不参与发票查重。
@@ -255,7 +255,7 @@
| 归档内容 | 要求 |
| --- | --- |
| 报销单快照 | 包含报销单号、申请人、实际报销人、部门、项目、明细、金额、状态和提交时间 |
| 发票原件 | 保留上传的图片或 PDF 原件,不以 OCR 结果替代原件 |
| 发票原件 | 保留上传的 PDF 原件,不以 OCR 结果替代原件 |
| OCR 结果 | 保留识别字段、识别状态、失败原因和人工修正记录 |
| 附件 | 保留支付截图、订单、合同、聊天记录、情况说明等非发票材料 |
| 审批历史 | 保留提交、通过、驳回、退回、撤回、转审和处理意见 |
@@ -3,5 +3,5 @@ R1,发布候选证据整理,固定首轮主线 release candidate,汇总 A1-A8
R2,本地门禁与 Runbook 补强,把 A8 本地回归真实需要的本地服务参数、命令顺序、live smoke 检查和 SKIP 规则写入当前操作手册,避免后续误判。,1) Runbook 写明 SERVE_CLIENT_DIST、OCR_PROVIDER、IM_NOTIFIER_ENABLED、FIELD_ENCRYPTION_KEY 和项目 feature env 的本地门禁用途;2) live smoke 清单链接到发布准备清单;3) 不新增业务 API、数据库迁移或部署操作。,流程级验证:人工阅读 Runbook 能复现 A8 本地门禁准备;命令:rg -n '发布候选|SERVE_CLIENT_DIST|OCR_PROVIDER|IM_NOTIFIER_ENABLED|FIELD_ENCRYPTION_KEY|PROJECT_DOMAIN_ENABLED|release-readiness-checklist' docs/trialrun-runbook.md docs/trialrun/live-smoke-readiness.md docs/文档索引.md。,rg|manual,DONE,DONE,DONE,docs/trialrun-runbook.md|docs/trialrun/live-smoke-readiness.md|docs/文档索引.md,"[""R1""]",仅补文档和清单;测试 FIELD_ENCRYPTION_KEY 只能用于本地回归,生产必须使用安全密钥且不得提交。
R3,权限直连审计,在部署前用员工、主管、财务、管理员账号直连主路径和备用路径,确认导航隐藏没有掩盖服务端授权缺口。,1) 形成角色 x 路径允许/拒绝矩阵;2) 普通员工直连预算、AP、ERP、完整差旅、票据中心、合同台账、付款批次等备用路径不得获得越权数据;3) 若发现服务端权限缺口,先拆独立修复 Issue,不进入部署。,流程级验证:分别登录员工、主管、财务、管理员账号 -> 直连 /budgets、/ap、/system/integrations/erp、/travel-requests、/invoice-pool、/contracts-registry、/payment-batches 和对应 API -> 记录 HTTP 状态、页面提示和是否泄露数据;命令:cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts login-portal-ux.spec.ts --workers=1。,playwright|curl|manual,DONE,DONE,DONE,client/tests/e2e/permission-direct-access.spec.ts|docs/trialrun/permission-direct-access-audit.md|docs/trialrun/release-readiness-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv,"[""R2""]",R3 已完成:新增权限直连 E2E 和审计记录;首次 E2E 因 127.0.0.1:3000 未启动失败,启动本地 OA 服务后重跑通过 10 passed;typecheck/build/CSV/diff check 通过;未发现权限泄漏,未改业务代码,R4 仍未执行。
R4,部署预检,检查生产/公网环境变量、数据库迁移状态、备份、回滚命令、Caddy/systemd 状态、/ready 和前端资源版本;未经批准不部署。,1) 生产环境变量缺项和敏感项不打印;2) 数据库迁移状态明确且有备份/回滚路径;3) Caddy/systemd 或既有部署方式健康;4) /ready 与前端资源版本可核对;5) 若需要修改生产配置,必须先获得确认。,流程级验证:只读检查部署目录、服务状态和 /ready;命令:node scripts/qa/check-live-smoke-env.mjs;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;生产变更前必须停止等待批准。,ssh|bash|node|manual,DONE,DONE,DONE,deploy/*|scripts/qa/*|docs/trialrun/release-readiness-checklist.md,"[""R3""]",R4 完成:先发现远端运行时代码未对齐,后续按治理合同完成部署前备份、D3 受控同步和 D4 只读复检;远端关键运行时文件哈希已对齐,本机/公网 /ready 200,Prisma up to date,Caddy 正常,企业微信 config 不含 secret,公网 bundle 扫描 0 findings;R5 仍未执行。
R5,公网企业微信和真实 OCR live smoke,在 R4 预检通过且真实测试账号、企业微信配置和真实 OCR 样票齐备后,执行公网登录、企业微信入口、移动审批、报销/OCR 上传和通知 deeplink。,1) LIVE_SMOKE_* 从缺失变为可执行;2) 企业微信 config、OAuth 跳转和手机入口可验证;3) 真实 OCR 样票上传能进入可解释终态;4) 缺配置时继续 SKIP,不得宣称真机或真实 OCR 通过。,流程级验证:node scripts/qa/check-live-smoke-env.mjs --strict;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机:手机企业微信工作台打开海音达工作台 -> deeplink 回 OA 审批或详情页 -> 记录结果。,node|bash|playwright|manual,DOING,TODO,TODO,scripts/qa/*|docs/trialrun/live-smoke-readiness.md|docs/trialrun/real-device-public-checklist.md,"[""R4""]",R5 smoke 密码重置合同 issues/2026-05-10_17-10-42-r5-smoke-password-reset-and-live-smoke.csv 已完成 K1-K5;SMOKE001/SMOKE002 已重置为仓库外安全凭据,登录标识按公网 auth 契约使用 smoke 邮箱;check-live-smoke-env --strict 通过;run-live-smoke-readiness.sh 通过,证据目录 artifacts/qa-flow/live-smoke-readiness-20260510-172421,公网 bundle findings=0,live smoke 3 passed,真实 OCR 样票不再 SKIP 且终态为重复发票/ocr_failed 可解释;pending OCR 辅助扫描因缺 DATABASE_URL 继续 SKIP;K4 审计 authSessions 555->572、auditLogs 258->263,发票/报销数量未新增;本轮 R5 真机反馈与正式运行口径代码收口已完成:issues/2026-05-10_17-47-39-r5-real-device-feedback-mobile-ux.csv M1-M5 DONE,后端禁止自审/admin-only 审批,移动底部 Tab 改为报销,头像中文首字;审批规约与正式组织口径文档同步已完成:issues/2026-05-10_19-24-06-approval-governance-doc-sync.csv G1-G5 DONE;O2 正式组织配置已完成并部署最新 RC,C2/C3 R4 复检通过;C4 自动化 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-201403,SMOKE002 请假待办已确认分配给 majianyong。主 R5 仍保持 DOING/TODO/TODO:缺企业微信真机正式矩阵截图/录屏,缺 majianyong 实际审批、wangshunli 二审或主管本人申请人工验收;R6 保持 TODO。 S 合同 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv 已完成 S1-S3:为避免真实负责人/主管/财务参与验收,已创建/更新 SMOKE101 测试主管、SMOKE201 测试负责人、SMOKE301 测试财务、SMOKE302 测试财务主管、SMOKE401 测试 HR;SMOKE002 已改挂 SMOKE101,SMOKE001 仅测试管理员且 admin 不代表业务审批权;后续 R5/O3 必须使用测试替身账号链路和企业微信测试成员,缺测试成员则企业微信原生身份验收 BLOCKED,R6 继续 TODO。 S4 已完成自动化收口:公网 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-205136;测试替身正式链路已覆盖 SMOKE002->SMOKE101、SMOKE101->SMOKE201、SMOKE201 自身阻断、SMOKE001 admin-only 403、小额 finance 与大额 ceo/finance_mgr;主 R5 仍 DOING/TODO/TODO:缺企业微信测试成员真机工作台、移动审批、报销详情、通知 deeplink 截图/录屏记录;R6 继续 TODO。
R5,公网企业微信和真实 OCR live smoke,在 R4 预检通过且真实测试账号、企业微信配置和真实 OCR 样票齐备后,执行公网登录、企业微信入口、移动审批、报销/OCR 上传和通知 deeplink。,1) LIVE_SMOKE_* 从缺失变为可执行;2) 企业微信 config、OAuth 跳转和手机入口可验证;3) 真实 OCR 样票上传能进入可解释终态;4) 缺配置时继续 SKIP,不得宣称真机或真实 OCR 通过。,流程级验证:node scripts/qa/check-live-smoke-env.mjs --strict;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机:手机企业微信工作台打开海音达工作台 -> deeplink 回 OA 审批或详情页 -> 记录结果。,node|bash|playwright|manual,DOING,TODO,TODO,scripts/qa/*|docs/trialrun/live-smoke-readiness.md|docs/trialrun/real-device-public-checklist.md,"[""R4""]",R5 smoke 密码重置合同 issues/2026-05-10_17-10-42-r5-smoke-password-reset-and-live-smoke.csv 已完成 K1-K5;SMOKE001/SMOKE002 已重置为仓库外安全凭据,登录标识按公网 auth 契约使用 smoke 邮箱;check-live-smoke-env --strict 通过;run-live-smoke-readiness.sh 通过,证据目录 artifacts/qa-flow/live-smoke-readiness-20260510-172421,公网 bundle findings=0,live smoke 3 passed,真实 OCR 样票不再 SKIP 且终态为重复发票/ocr_failed 可解释;pending OCR 辅助扫描因缺 DATABASE_URL 继续 SKIP;K4 审计 authSessions 555->572、auditLogs 258->263,发票/报销数量未新增;本轮 R5 真机反馈与正式运行口径代码收口已完成:issues/2026-05-10_17-47-39-r5-real-device-feedback-mobile-ux.csv M1-M5 DONE,后端禁止自审/admin-only 审批,移动底部 Tab 改为报销,头像中文首字;审批规约与正式组织口径文档同步已完成:issues/2026-05-10_19-24-06-approval-governance-doc-sync.csv G1-G5 DONE;O2 正式组织配置已完成并部署最新 RC,C2/C3 R4 复检通过;C4 自动化 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-201403,SMOKE002 请假待办已确认分配给 majianyong。主 R5 仍保持 DOING/TODO/TODO:缺企业微信真机正式矩阵截图/录屏,缺 majianyong 实际审批、wangshunli 二审或主管本人申请人工验收;R6 保持 TODO。 S 合同 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv 已完成 S1-S3:为避免真实负责人/主管/财务参与验收,已创建/更新 SMOKE101 测试主管、SMOKE201 测试负责人、SMOKE301 测试财务、SMOKE302 测试财务主管、SMOKE401 测试 HR;SMOKE002 已改挂 SMOKE101,SMOKE001 仅测试管理员且 admin 不代表业务审批权;后续 R5/O3 必须使用测试替身账号链路和企业微信测试成员,缺测试成员则企业微信原生身份验收 BLOCKED,R6 继续 TODO。 S4 已完成自动化收口:公网 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-205136;测试替身正式链路已覆盖 SMOKE002->SMOKE101、SMOKE101->SMOKE201、SMOKE201 自身阻断、SMOKE001 admin-only 403、小额 finance 与大额 ceo/finance_mgr;主 R5 仍 DOING/TODO/TODO:缺企业微信测试成员真机工作台、移动审批、报销详情、通知 deeplink 截图/录屏记录;R6 继续 TODO。 R5 updated 2026-05-10:企业微信真机提交人与主管链路阶段性通过:oa_smoke_01->SMOKE002 绑定/免登录/提交通过,oa_smoke_02->SMOKE101 绑定/审批待办/审批详情结果通过;发现移动底部导航和报销详情深层页在 iPhone 11/Redmi K60 下不稳定,以及发票上传需 PDF-only 收口。已通过 issues/2026-05-10_22-44-06-r5-mobile-viewport-pdf-invoice.csv 在本地修复并验证,但尚未部署公网,R5 继续 DOING/TODO/TODO;部署、R4 复检、公网 live smoke、PDF 样票真实 OCR、企业微信真机复测和负责人/财务替身矩阵完成前不得进入 R6。
R6,小范围试运行,公网和真机 smoke 通过后,只开放 2-3 个内部用户按清单试运行报销、请假、采购、审批、企业微信移动入口、付款和导出;反馈转新 Issue。,1) 试运行名单、角色、时间窗口和回滚联系人明确;2) 员工提交、审批人审批、财务付款/导出、企业微信 deeplink 回 OA 均有记录;3) 每日问题只登记和分级,不现场扩大 P2 功能范围;4) 试运行结束形成继续/回滚/修复判断。,流程级验证:员工提交报销/请假/采购 -> 主管或财务审批 -> 财务付款或导出 -> 企业微信通知 deeplink 回 OA -> 管理员检查审计和状态;人工记录问题、截图和处理结论。,manual|playwright,TODO,TODO,TODO,docs/trialrun/release-readiness-checklist.md|docs/trialrun/manual-trialrun-report.md|issues/*.csv,"[""R5""]",小范围试运行前不得扩 P2 预算、ERP、AI、完整差旅、复杂 BPM;反馈必须进入新 Issue CSV。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
3 R2 本地门禁与 Runbook 补强 把 A8 本地回归真实需要的本地服务参数、命令顺序、live smoke 检查和 SKIP 规则写入当前操作手册,避免后续误判。 1) Runbook 写明 SERVE_CLIENT_DIST、OCR_PROVIDER、IM_NOTIFIER_ENABLED、FIELD_ENCRYPTION_KEY 和项目 feature env 的本地门禁用途;2) live smoke 清单链接到发布准备清单;3) 不新增业务 API、数据库迁移或部署操作。 流程级验证:人工阅读 Runbook 能复现 A8 本地门禁准备;命令:rg -n '发布候选|SERVE_CLIENT_DIST|OCR_PROVIDER|IM_NOTIFIER_ENABLED|FIELD_ENCRYPTION_KEY|PROJECT_DOMAIN_ENABLED|release-readiness-checklist' docs/trialrun-runbook.md docs/trialrun/live-smoke-readiness.md docs/文档索引.md。 rg|manual DONE DONE DONE docs/trialrun-runbook.md|docs/trialrun/live-smoke-readiness.md|docs/文档索引.md ["R1"] 仅补文档和清单;测试 FIELD_ENCRYPTION_KEY 只能用于本地回归,生产必须使用安全密钥且不得提交。
4 R3 权限直连审计 在部署前用员工、主管、财务、管理员账号直连主路径和备用路径,确认导航隐藏没有掩盖服务端授权缺口。 1) 形成角色 x 路径允许/拒绝矩阵;2) 普通员工直连预算、AP、ERP、完整差旅、票据中心、合同台账、付款批次等备用路径不得获得越权数据;3) 若发现服务端权限缺口,先拆独立修复 Issue,不进入部署。 流程级验证:分别登录员工、主管、财务、管理员账号 -> 直连 /budgets、/ap、/system/integrations/erp、/travel-requests、/invoice-pool、/contracts-registry、/payment-batches 和对应 API -> 记录 HTTP 状态、页面提示和是否泄露数据;命令:cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts login-portal-ux.spec.ts --workers=1。 playwright|curl|manual DONE DONE DONE client/tests/e2e/permission-direct-access.spec.ts|docs/trialrun/permission-direct-access-audit.md|docs/trialrun/release-readiness-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv ["R2"] R3 已完成:新增权限直连 E2E 和审计记录;首次 E2E 因 127.0.0.1:3000 未启动失败,启动本地 OA 服务后重跑通过 10 passed;typecheck/build/CSV/diff check 通过;未发现权限泄漏,未改业务代码,R4 仍未执行。
5 R4 部署预检 检查生产/公网环境变量、数据库迁移状态、备份、回滚命令、Caddy/systemd 状态、/ready 和前端资源版本;未经批准不部署。 1) 生产环境变量缺项和敏感项不打印;2) 数据库迁移状态明确且有备份/回滚路径;3) Caddy/systemd 或既有部署方式健康;4) /ready 与前端资源版本可核对;5) 若需要修改生产配置,必须先获得确认。 流程级验证:只读检查部署目录、服务状态和 /ready;命令:node scripts/qa/check-live-smoke-env.mjs;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;生产变更前必须停止等待批准。 ssh|bash|node|manual DONE DONE DONE deploy/*|scripts/qa/*|docs/trialrun/release-readiness-checklist.md ["R3"] R4 完成:先发现远端运行时代码未对齐,后续按治理合同完成部署前备份、D3 受控同步和 D4 只读复检;远端关键运行时文件哈希已对齐,本机/公网 /ready 200,Prisma up to date,Caddy 正常,企业微信 config 不含 secret,公网 bundle 扫描 0 findings;R5 仍未执行。
6 R5 公网企业微信和真实 OCR live smoke 在 R4 预检通过且真实测试账号、企业微信配置和真实 OCR 样票齐备后,执行公网登录、企业微信入口、移动审批、报销/OCR 上传和通知 deeplink。 1) LIVE_SMOKE_* 从缺失变为可执行;2) 企业微信 config、OAuth 跳转和手机入口可验证;3) 真实 OCR 样票上传能进入可解释终态;4) 缺配置时继续 SKIP,不得宣称真机或真实 OCR 通过。 流程级验证:node scripts/qa/check-live-smoke-env.mjs --strict;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机:手机企业微信工作台打开海音达工作台 -> deeplink 回 OA 审批或详情页 -> 记录结果。 node|bash|playwright|manual DOING TODO TODO scripts/qa/*|docs/trialrun/live-smoke-readiness.md|docs/trialrun/real-device-public-checklist.md ["R4"] R5 smoke 密码重置合同 issues/2026-05-10_17-10-42-r5-smoke-password-reset-and-live-smoke.csv 已完成 K1-K5;SMOKE001/SMOKE002 已重置为仓库外安全凭据,登录标识按公网 auth 契约使用 smoke 邮箱;check-live-smoke-env --strict 通过;run-live-smoke-readiness.sh 通过,证据目录 artifacts/qa-flow/live-smoke-readiness-20260510-172421,公网 bundle findings=0,live smoke 3 passed,真实 OCR 样票不再 SKIP 且终态为重复发票/ocr_failed 可解释;pending OCR 辅助扫描因缺 DATABASE_URL 继续 SKIP;K4 审计 authSessions 555->572、auditLogs 258->263,发票/报销数量未新增;本轮 R5 真机反馈与正式运行口径代码收口已完成:issues/2026-05-10_17-47-39-r5-real-device-feedback-mobile-ux.csv M1-M5 DONE,后端禁止自审/admin-only 审批,移动底部 Tab 改为报销,头像中文首字;审批规约与正式组织口径文档同步已完成:issues/2026-05-10_19-24-06-approval-governance-doc-sync.csv G1-G5 DONE;O2 正式组织配置已完成并部署最新 RC,C2/C3 R4 复检通过;C4 自动化 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-201403,SMOKE002 请假待办已确认分配给 majianyong。主 R5 仍保持 DOING/TODO/TODO:缺企业微信真机正式矩阵截图/录屏,缺 majianyong 实际审批、wangshunli 二审或主管本人申请人工验收;R6 保持 TODO。 S 合同 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv 已完成 S1-S3:为避免真实负责人/主管/财务参与验收,已创建/更新 SMOKE101 测试主管、SMOKE201 测试负责人、SMOKE301 测试财务、SMOKE302 测试财务主管、SMOKE401 测试 HR;SMOKE002 已改挂 SMOKE101,SMOKE001 仅测试管理员且 admin 不代表业务审批权;后续 R5/O3 必须使用测试替身账号链路和企业微信测试成员,缺测试成员则企业微信原生身份验收 BLOCKED,R6 继续 TODO。 S4 已完成自动化收口:公网 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-205136;测试替身正式链路已覆盖 SMOKE002->SMOKE101、SMOKE101->SMOKE201、SMOKE201 自身阻断、SMOKE001 admin-only 403、小额 finance 与大额 ceo/finance_mgr;主 R5 仍 DOING/TODO/TODO:缺企业微信测试成员真机工作台、移动审批、报销详情、通知 deeplink 截图/录屏记录;R6 继续 TODO。 R5 smoke 密码重置合同 issues/2026-05-10_17-10-42-r5-smoke-password-reset-and-live-smoke.csv 已完成 K1-K5;SMOKE001/SMOKE002 已重置为仓库外安全凭据,登录标识按公网 auth 契约使用 smoke 邮箱;check-live-smoke-env --strict 通过;run-live-smoke-readiness.sh 通过,证据目录 artifacts/qa-flow/live-smoke-readiness-20260510-172421,公网 bundle findings=0,live smoke 3 passed,真实 OCR 样票不再 SKIP 且终态为重复发票/ocr_failed 可解释;pending OCR 辅助扫描因缺 DATABASE_URL 继续 SKIP;K4 审计 authSessions 555->572、auditLogs 258->263,发票/报销数量未新增;本轮 R5 真机反馈与正式运行口径代码收口已完成:issues/2026-05-10_17-47-39-r5-real-device-feedback-mobile-ux.csv M1-M5 DONE,后端禁止自审/admin-only 审批,移动底部 Tab 改为报销,头像中文首字;审批规约与正式组织口径文档同步已完成:issues/2026-05-10_19-24-06-approval-governance-doc-sync.csv G1-G5 DONE;O2 正式组织配置已完成并部署最新 RC,C2/C3 R4 复检通过;C4 自动化 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-201403,SMOKE002 请假待办已确认分配给 majianyong。主 R5 仍保持 DOING/TODO/TODO:缺企业微信真机正式矩阵截图/录屏,缺 majianyong 实际审批、wangshunli 二审或主管本人申请人工验收;R6 保持 TODO。 S 合同 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv 已完成 S1-S3:为避免真实负责人/主管/财务参与验收,已创建/更新 SMOKE101 测试主管、SMOKE201 测试负责人、SMOKE301 测试财务、SMOKE302 测试财务主管、SMOKE401 测试 HR;SMOKE002 已改挂 SMOKE101,SMOKE001 仅测试管理员且 admin 不代表业务审批权;后续 R5/O3 必须使用测试替身账号链路和企业微信测试成员,缺测试成员则企业微信原生身份验收 BLOCKED,R6 继续 TODO。 S4 已完成自动化收口:公网 live smoke/真实 OCR 再次通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-205136;测试替身正式链路已覆盖 SMOKE002->SMOKE101、SMOKE101->SMOKE201、SMOKE201 自身阻断、SMOKE001 admin-only 403、小额 finance 与大额 ceo/finance_mgr;主 R5 仍 DOING/TODO/TODO:缺企业微信测试成员真机工作台、移动审批、报销详情、通知 deeplink 截图/录屏记录;R6 继续 TODO。 R5 updated 2026-05-10:企业微信真机提交人与主管链路阶段性通过:oa_smoke_01->SMOKE002 绑定/免登录/提交通过,oa_smoke_02->SMOKE101 绑定/审批待办/审批详情结果通过;发现移动底部导航和报销详情深层页在 iPhone 11/Redmi K60 下不稳定,以及发票上传需 PDF-only 收口。已通过 issues/2026-05-10_22-44-06-r5-mobile-viewport-pdf-invoice.csv 在本地修复并验证,但尚未部署公网,R5 继续 DOING/TODO/TODO;部署、R4 复检、公网 live smoke、PDF 样票真实 OCR、企业微信真机复测和负责人/财务替身矩阵完成前不得进入 R6。
7 R6 小范围试运行 公网和真机 smoke 通过后,只开放 2-3 个内部用户按清单试运行报销、请假、采购、审批、企业微信移动入口、付款和导出;反馈转新 Issue。 1) 试运行名单、角色、时间窗口和回滚联系人明确;2) 员工提交、审批人审批、财务付款/导出、企业微信 deeplink 回 OA 均有记录;3) 每日问题只登记和分级,不现场扩大 P2 功能范围;4) 试运行结束形成继续/回滚/修复判断。 流程级验证:员工提交报销/请假/采购 -> 主管或财务审批 -> 财务付款或导出 -> 企业微信通知 deeplink 回 OA -> 管理员检查审计和状态;人工记录问题、截图和处理结论。 manual|playwright TODO TODO TODO docs/trialrun/release-readiness-checklist.md|docs/trialrun/manual-trialrun-report.md|issues/*.csv ["R5"] 小范围试运行前不得扩 P2 预算、ERP、AI、完整差旅、复杂 BPM;反馈必须进入新 Issue CSV。
@@ -1,4 +1,4 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
O1,正式审批组织配置确认,按正式公司运行口径确认公网组织关系和业务审批角色:majianyong 是员工可见直属主管;wangshunli 是后台公司负责人,使用 ceo+admin,不配置为普通 manager。,1) 远端当前角色和直属上级只读核实;2) 形成待写配置清单;3) 明确 admin 不自动获得业务审批权;4) 未经批准不写远端数据库。,流程级验证:只读查询公网员工 wangshunli/majianyong 的 roleCodes、directManagerId、department;核对 docs/需求-统一审批中心规约.md;不执行 UPDATE。,ssh|manual,DONE,DONE,DONE,external|issues/2026-05-10_18-25-33-formal-approval-org-config.csv,[],"O1 read-only 2026-05-10T11:41:02Z:wangshunli=HYD000006 active dept=管理部 roles=[admin] directManager=null;majianyong=HYD000007 active dept=管理部 roles=[manager] directManager=null;SMOKE001 active roles=[admin,employee] directManager=null;SMOKE002 active roles=[employee] directManager=SMOKE001。docs/需求-统一审批中心规约.md 已核对正式口径:admin 不代表业务审批权。O2 required=yes;待写清单:employee_roles(wangshunli) add ceo rollback=delete ceo;employees(majianyong).direct_manager_id null -> wangshunli rollback=null;employees(SMOKE002).direct_manager_id SMOKE001 -> majianyong rollback=SMOKE001;可选 employee_roles(SMOKE001) 保持 admin+employee 仅作 smoke admin,不作为正式主管;不得把 wangshunli 配为 manager。未部署、未迁移、未重启、未改远端 .env、未执行 UPDATE。"
O2,专用正式测试账号准备,准备不使用真实员工日常账号的公网专用测试账号,并按正式组织关系挂到 majianyong 主管链路。,1) 专用员工、主管、财务或负责人测试账号 active;2) 密码只保存在仓库外安全位置;3) 自动化不使用真实员工日常账号;4) 账号角色满足正式审批链验证。,流程级验证:只读检查账号存在与状态;如需创建或重置密码,必须另行批准远端写操作;node scripts/qa/check-live-smoke-env.mjs --strict。,ssh|node|manual,DONE,DONE,DONE,external,"[""O1""]","O2 completed 2026-05-10T12:03:14Z:受控写入三项正式组织配置;before wangshunli roles=[admin] directManager=null, majianyong roles=[manager] directManager=null, SMOKE002 directManager=SMOKE001;after wangshunli roles=[admin,ceo] directManager=null, majianyong roles=[manager] directManager=wangshunli, SMOKE002 directManager=majianyong;SMOKE001 保持 admin+employee 仅作 smoke admin。回滚:删除 wangshunli 的 ceo 角色;majianyong.direct_manager_id 置空;SMOKE002.direct_manager_id 恢复 SMOKE001。node scripts/qa/check-live-smoke-env.mjs --strict 通过,liveSmokeReady=true liveOcrReady=true publicReady=true wecom=ready。未部署、未迁移、未重启、未改远端 .env、未改密码、未清理数据。"
O3,正式审批链公网验收,使用专用测试替身账号验证正式审批链:SMOKE002 -> SMOKE101 -> SMOKE201 -> SMOKE302/SMOKE301;生产组织事实源仍为 majianyong/wangshunli,但不使用真实人员日常账号参与验收。,1) 普通低/中额员工单据由 SMOKE101 处理;2) 高额/高风险/例外进入 SMOKE101 -> SMOKE201,并按现有规则进入 SMOKE302/SMOKE301 财务节点;3) SMOKE101 本人申请由 SMOKE201 处理;4) SMOKE201 本人不能自审;5) SMOKE001 仅 admin 时不能审批业务单据;6) 企业微信测试成员真机记录完成后才可 DONE。,流程级验证:公网/企业微信测试成员使用 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 提交和处理报销或请假 -> 检查 workflow task、审批历史、通知 deeplink 和业务状态;失败项记录截图/录屏位置;不使用真实员工日常账号。,playwright|manual,TODO,TODO,TODO,docs/trialrun/real-device-public-checklist.md,"[""O2""]",O3 updated for test substitutes:S 合同已创建正式测试组织账号矩阵,验收链路改为 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;SMOKE001 仅测试管理员,不代表业务审批权;真实 majianyong/wangshunli 仍是生产组织事实源,但不参与 R5/O3 日常账号验收。O3 仍 TODO:待 S4 自动化正式测试链路、企业微信测试成员真机矩阵截图/录屏、财务复核/付款或导出权限验证;如无企业微信测试成员,记录 BLOCKED,不得把主 R5 标记为 DONE。 S4 自动化已通过:SMOKE002->SMOKE101 请假分配、SMOKE101->SMOKE201 主管本人分配、SMOKE201 自身提交阻断、SMOKE001 admin-only 审批 403、小额报销进入 finance role、大额报销进入 ceo -> finance_mgr role 均已验证;证据见 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv S4 Notes。O3 仍 TODO:缺企业微信测试成员真机矩阵截图/录屏和 deeplink/工作台入口人工记录;未完成前主 R5 不得 DONE。
O3,正式审批链公网验收,使用专用测试替身账号验证正式审批链:SMOKE002 -> SMOKE101 -> SMOKE201 -> SMOKE302/SMOKE301;生产组织事实源仍为 majianyong/wangshunli,但不使用真实人员日常账号参与验收。,1) 普通低/中额员工单据由 SMOKE101 处理;2) 高额/高风险/例外进入 SMOKE101 -> SMOKE201,并按现有规则进入 SMOKE302/SMOKE301 财务节点;3) SMOKE101 本人申请由 SMOKE201 处理;4) SMOKE201 本人不能自审;5) SMOKE001 仅 admin 时不能审批业务单据;6) 企业微信测试成员真机记录完成后才可 DONE。,流程级验证:公网/企业微信测试成员使用 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 提交和处理报销或请假 -> 检查 workflow task、审批历史、通知 deeplink 和业务状态;失败项记录截图/录屏位置;不使用真实员工日常账号。,playwright|manual,TODO,TODO,TODO,docs/trialrun/real-device-public-checklist.md,"[""O2""]",O3 updated for test substitutes:S 合同已创建正式测试组织账号矩阵,验收链路改为 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;SMOKE001 仅测试管理员,不代表业务审批权;真实 majianyong/wangshunli 仍是生产组织事实源,但不参与 R5/O3 日常账号验收。O3 仍 TODO:待 S4 自动化正式测试链路、企业微信测试成员真机矩阵截图/录屏、财务复核/付款或导出权限验证;如无企业微信测试成员,记录 BLOCKED,不得把主 R5 标记为 DONE。 S4 自动化已通过:SMOKE002->SMOKE101 请假分配、SMOKE101->SMOKE201 主管本人分配、SMOKE201 自身提交阻断、SMOKE001 admin-only 审批 403、小额报销进入 finance role、大额报销进入 ceo -> finance_mgr role 均已验证;证据见 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv S4 Notes。O3 仍 TODO:缺企业微信测试成员真机矩阵截图/录屏和 deeplink/工作台入口人工记录;未完成前主 R5 不得 DONE。 O3 updated 2026-05-10:提交人与主管替身真机链路已通过:oa_smoke_01->SMOKE002 提交,oa_smoke_02->SMOKE101 审批,工作台入口和免登录通过;O3 仍 TODO,因为本轮移动视口/PDF-only 修复尚未部署复测,且 SMOKE201/SMOKE301/SMOKE302 的负责人/财务替身真机矩阵、通知 deeplink 与财务复核/付款导出仍待完成。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 O1 正式审批组织配置确认 按正式公司运行口径确认公网组织关系和业务审批角色:majianyong 是员工可见直属主管;wangshunli 是后台公司负责人,使用 ceo+admin,不配置为普通 manager。 1) 远端当前角色和直属上级只读核实;2) 形成待写配置清单;3) 明确 admin 不自动获得业务审批权;4) 未经批准不写远端数据库。 流程级验证:只读查询公网员工 wangshunli/majianyong 的 roleCodes、directManagerId、department;核对 docs/需求-统一审批中心规约.md;不执行 UPDATE。 ssh|manual DONE DONE DONE external|issues/2026-05-10_18-25-33-formal-approval-org-config.csv [] O1 read-only 2026-05-10T11:41:02Z:wangshunli=HYD000006 active dept=管理部 roles=[admin] directManager=null;majianyong=HYD000007 active dept=管理部 roles=[manager] directManager=null;SMOKE001 active roles=[admin,employee] directManager=null;SMOKE002 active roles=[employee] directManager=SMOKE001。docs/需求-统一审批中心规约.md 已核对正式口径:admin 不代表业务审批权。O2 required=yes;待写清单:employee_roles(wangshunli) add ceo rollback=delete ceo;employees(majianyong).direct_manager_id null -> wangshunli rollback=null;employees(SMOKE002).direct_manager_id SMOKE001 -> majianyong rollback=SMOKE001;可选 employee_roles(SMOKE001) 保持 admin+employee 仅作 smoke admin,不作为正式主管;不得把 wangshunli 配为 manager。未部署、未迁移、未重启、未改远端 .env、未执行 UPDATE。
3 O2 专用正式测试账号准备 准备不使用真实员工日常账号的公网专用测试账号,并按正式组织关系挂到 majianyong 主管链路。 1) 专用员工、主管、财务或负责人测试账号 active;2) 密码只保存在仓库外安全位置;3) 自动化不使用真实员工日常账号;4) 账号角色满足正式审批链验证。 流程级验证:只读检查账号存在与状态;如需创建或重置密码,必须另行批准远端写操作;node scripts/qa/check-live-smoke-env.mjs --strict。 ssh|node|manual DONE DONE DONE external ["O1"] O2 completed 2026-05-10T12:03:14Z:受控写入三项正式组织配置;before wangshunli roles=[admin] directManager=null, majianyong roles=[manager] directManager=null, SMOKE002 directManager=SMOKE001;after wangshunli roles=[admin,ceo] directManager=null, majianyong roles=[manager] directManager=wangshunli, SMOKE002 directManager=majianyong;SMOKE001 保持 admin+employee 仅作 smoke admin。回滚:删除 wangshunli 的 ceo 角色;majianyong.direct_manager_id 置空;SMOKE002.direct_manager_id 恢复 SMOKE001。node scripts/qa/check-live-smoke-env.mjs --strict 通过,liveSmokeReady=true liveOcrReady=true publicReady=true wecom=ready。未部署、未迁移、未重启、未改远端 .env、未改密码、未清理数据。
4 O3 正式审批链公网验收 使用专用测试替身账号验证正式审批链:SMOKE002 -> SMOKE101 -> SMOKE201 -> SMOKE302/SMOKE301;生产组织事实源仍为 majianyong/wangshunli,但不使用真实人员日常账号参与验收。 1) 普通低/中额员工单据由 SMOKE101 处理;2) 高额/高风险/例外进入 SMOKE101 -> SMOKE201,并按现有规则进入 SMOKE302/SMOKE301 财务节点;3) SMOKE101 本人申请由 SMOKE201 处理;4) SMOKE201 本人不能自审;5) SMOKE001 仅 admin 时不能审批业务单据;6) 企业微信测试成员真机记录完成后才可 DONE。 流程级验证:公网/企业微信测试成员使用 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 提交和处理报销或请假 -> 检查 workflow task、审批历史、通知 deeplink 和业务状态;失败项记录截图/录屏位置;不使用真实员工日常账号。 playwright|manual TODO TODO TODO docs/trialrun/real-device-public-checklist.md ["O2"] O3 updated for test substitutes:S 合同已创建正式测试组织账号矩阵,验收链路改为 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;SMOKE001 仅测试管理员,不代表业务审批权;真实 majianyong/wangshunli 仍是生产组织事实源,但不参与 R5/O3 日常账号验收。O3 仍 TODO:待 S4 自动化正式测试链路、企业微信测试成员真机矩阵截图/录屏、财务复核/付款或导出权限验证;如无企业微信测试成员,记录 BLOCKED,不得把主 R5 标记为 DONE。 S4 自动化已通过:SMOKE002->SMOKE101 请假分配、SMOKE101->SMOKE201 主管本人分配、SMOKE201 自身提交阻断、SMOKE001 admin-only 审批 403、小额报销进入 finance role、大额报销进入 ceo -> finance_mgr role 均已验证;证据见 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv S4 Notes。O3 仍 TODO:缺企业微信测试成员真机矩阵截图/录屏和 deeplink/工作台入口人工记录;未完成前主 R5 不得 DONE。 O3 updated for test substitutes:S 合同已创建正式测试组织账号矩阵,验收链路改为 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;SMOKE001 仅测试管理员,不代表业务审批权;真实 majianyong/wangshunli 仍是生产组织事实源,但不参与 R5/O3 日常账号验收。O3 仍 TODO:待 S4 自动化正式测试链路、企业微信测试成员真机矩阵截图/录屏、财务复核/付款或导出权限验证;如无企业微信测试成员,记录 BLOCKED,不得把主 R5 标记为 DONE。 S4 自动化已通过:SMOKE002->SMOKE101 请假分配、SMOKE101->SMOKE201 主管本人分配、SMOKE201 自身提交阻断、SMOKE001 admin-only 审批 403、小额报销进入 finance role、大额报销进入 ceo -> finance_mgr role 均已验证;证据见 issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv S4 Notes。O3 仍 TODO:缺企业微信测试成员真机矩阵截图/录屏和 deeplink/工作台入口人工记录;未完成前主 R5 不得 DONE。 O3 updated 2026-05-10:提交人与主管替身真机链路已通过:oa_smoke_01->SMOKE002 提交,oa_smoke_02->SMOKE101 审批,工作台入口和免登录通过;O3 仍 TODO,因为本轮移动视口/PDF-only 修复尚未部署复测,且 SMOKE201/SMOKE301/SMOKE302 的负责人/财务替身真机矩阵、通知 deeplink 与财务复核/付款导出仍待完成。
@@ -3,4 +3,4 @@ S1,测试组织账号合同与现状确认,建立正式审批链测试替身账
S2,创建测试组织账号与本机凭据,创建或更新 SMOKE101/SMOKE201/SMOKE301/SMOKE302/SMOKE401,并更新 SMOKE002 直属主管到测试主管;密码只写入仓库外本机凭据文件。,1) SMOKE002 直属主管为 SMOKE101;2) SMOKE101=manager+employee 直属领导 SMOKE201;3) SMOKE201=ceo+employee;4) SMOKE301=finance+employee;5) SMOKE302=finance+finance_mgr+employee;6) SMOKE401=hr+employee;7) ~/.hyb-oa/live-smoke.env 权限 600 且不提交。,流程级验证:stat -c '%a %n' ~/.hyb-oa/live-smoke.env;远端 Prisma transaction 写入后只读复核 employeeNo/email/status/roles/directManager;node scripts/qa/check-live-smoke-env.mjs --strict。,ssh|node|manual,DONE,DONE,DONE,external|~/.hyb-oa/live-smoke.env,"[""S1""]",S2 completed:因上一轮工具输出污染过生成密码,本轮已轮换全部 SMOKE 测试密码;~/.hyb-oa/live-smoke.env 权限 600,未提交、未打印密码;远端 Prisma transaction 已创建/更新 SMOKE001/SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302/SMOKE401,部门=管理部,法人=试运行法体;SMOKE002.directManager=SMOKE101,SMOKE101.directManager=SMOKE201,SMOKE201=ceo+employee,SMOKE301=finance+employee,SMOKE302=finance+finance_mgr+employee,SMOKE401=hr+employee;已撤销测试账号旧会话 23 个;check-live-smoke-env --strict 通过。回滚:删除新增 SMOKE101/SMOKE201/SMOKE301/SMOKE302/SMOKE401,SMOKE002.direct_manager_id 恢复 HYD000007/majianyong,按需要再次重置 SMOKE001/SMOKE002 密码;未部署、未迁移、未重启、未改远端 .env。
S3,更新 R5/O3 与真机清单口径,把 O3/R5 和真机验收清单从真实人员链路调整为测试替身账号链路,同时保留生产组织事实源说明。,1) 真机矩阵使用 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;2) SMOKE001 仅管理员不具备业务审批权;3) 企业微信测试成员缺失时记录 BLOCKED;4) 主 R5/O3 不因文档调整自动完成。,流程级验证:rg -n 'SMOKE101|SMOKE201|SMOKE301|SMOKE302|测试替身|企业微信测试成员|admin 不代表业务审批权' docs/trialrun/real-device-public-checklist.md issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv issues/2026-05-10_18-25-33-formal-approval-org-config.csv;git diff --check。,rg|manual,DONE,DONE,DONE,docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv,"[""S2""]",S3 completed:真机清单、O3 合同和主 R5 Notes 已改为测试替身账号验收口径;生产正式组织事实源仍为 majianyong/wangshunli,但 R5/O3 验收使用 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;SMOKE001 仅测试管理员,不代表业务审批权;若无企业微信测试成员,企业微信原生身份验收记 BLOCKED,R5/O3 不得 DONE。
S4,测试组织自动化验收,用测试组织账号执行公网自动化 live smoke、真实 OCR 和正式审批链自动化覆盖,验证不依赖真实员工。,1) run-live-smoke-readiness.sh 通过;2) 普通员工低/中额待办给 SMOKE101;3) 高额/例外链路进入 SMOKE101->SMOKE201 或按现有规则进入财务节点;4) SMOKE101 本人申请给 SMOKE201;5) SMOKE201 自审被阻断或进入非本人兜底;6) SMOKE001 不能仅凭 admin 审批;7) 财务/财务主管权限由 SMOKE301/SMOKE302 验证。,流程级验证:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;执行临时/仓库脚本提交请假/报销/采购并只读检查 workflowTask assignee;远端 audit-smoke-data 只读统计。,bash|node|ssh|playwright,DONE,DONE,DONE,scripts/qa/*|external,"[""S3""]","S4 completed:run-live-smoke-readiness.sh 通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-205136,public-bundle findings=0,live smoke 3 passed,真实 OCR 样票不再 SKIP,终态 ocr_failed/重复发票可解释,pending OCR 因缺 DATABASE_URL 辅助 SKIP;临时正式测试组织链路脚本通过:SMOKE002 请假 214cb9d1-4b5a-4cef-9b3f-07243e9ea100 -> task 8a72bc3a-7773-4cc9-af3f-ce8b08c66a72,由 SMOKE101 可见;SMOKE001 admin-only 审批该任务被拒绝 403/PERM_002;SMOKE101 本人请假 af74a8e3-3df4-4a0b-b33d-8ab1f9116652 -> task da6cc325-4621-4992-a5a8-96ffd56b442a,由 SMOKE201 可见;SMOKE201 本人请假 81dcded2-e455-4568-9d25-9152f725ef33 提交被 409/VALID_001 阻断,details.workflow=no_actionable_approver reason=no_manager_found;小额报销 735095fa-dec1-492b-ab00-1d042a85d838 经 SMOKE101 审批后进入 finance role task 262f25d2-0203-470b-a4fc-fa4f81d62448,可由 SMOKE301 验证;大额报销 edfc6bbb-3210-433d-94fe-61eaacfb1eff 链路为 SMOKE101 task 177b3a9e-67fe-447d-92f4-96a652a06841 -> ceo role task 2b3a28d9-bfbb-42f8-8349-870d488577b6 -> finance_mgr role task 61af7b2f-5463-4c1c-97e9-b1c68dad6b63,可由 SMOKE302 验证;只读 smoke 审计 employees=7, authSessions=613, reimbursements=3, reimbursementItems=2, invoices=7, leaveRequests=4, workflowInstances=5, workflowTasksAssigned=4, workflowTasksCompleted=3, workflowHistory=8, notifications=6, auditLogs=280;本轮不清理测试数据,清理需另拆 destructive cleanup Issue。"
S5,企业微信真机门禁与状态回填,使用企业微信测试成员绑定 OA 测试账号并完成真机矩阵;若测试成员缺失,记录 BLOCKED,不宣称 R5 通过。,1) 企业微信工作台测试成员可进入 OA;2) 移动首页、审批待办、报销详情、通知 deeplink 有截图/录屏位置;3) O3/R5 仅在真机和自动化均通过后 DONE;4) R6 仍等待 R5 完成。,流程级验证:按 docs/trialrun/real-device-public-checklist.md 用测试成员执行;校验新合同、O3 合同和主 R5 合同;git diff --check。,manual|python3,DOING,TODO,TODO,docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv,"[""S4""]",S5 BLOCKED:自动化和测试替身审批链已通过,但尚无企业微信侧测试成员绑定 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 的真机截图或录屏证据;因此 S5、O3、主 R5 继续保持未完成,不能宣称企业微信原生身份验收或 R6 小范围试运行已解锁。
S5,企业微信真机门禁与状态回填,使用企业微信测试成员绑定 OA 测试账号并完成真机矩阵;若测试成员缺失,记录 BLOCKED,不宣称 R5 通过。,1) 企业微信工作台测试成员可进入 OA;2) 移动首页、审批待办、报销详情、通知 deeplink 有截图/录屏位置;3) O3/R5 仅在真机和自动化均通过后 DONE;4) R6 仍等待 R5 完成。,流程级验证:按 docs/trialrun/real-device-public-checklist.md 用测试成员执行;校验新合同、O3 合同和主 R5 合同;git diff --check。,manual|python3,DOING,TODO,TODO,docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv,"[""S4""]",S5 BLOCKED:自动化和测试替身审批链已通过,但尚无企业微信侧测试成员绑定 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 的真机截图或录屏证据;因此 S5、O3、主 R5 继续保持未完成,不能宣称企业微信原生身份验收或 R6 小范围试运行已解锁。 S5 updated 2026-05-10:用户已用 iPhone 11/iOS14 与 Redmi K60/MIUI 最新企业微信真机确认 oa_smoke_01->SMOKE002、oa_smoke_02->SMOKE101 绑定通过,工作台进入 OA、已绑定免登录、SMOKE002 提交单据、SMOKE101 审批待办与审批详情/结果通过;当前 S5 仍 DOING/TODO/TODO,因为移动底部导航和报销详情深层页修复、PDF-only 发票上传需部署后复测,且 SMOKE201/SMOKE301/SMOKE302 负责人/财务替身企业微信真机矩阵仍待补齐。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
3 S2 创建测试组织账号与本机凭据 创建或更新 SMOKE101/SMOKE201/SMOKE301/SMOKE302/SMOKE401,并更新 SMOKE002 直属主管到测试主管;密码只写入仓库外本机凭据文件。 1) SMOKE002 直属主管为 SMOKE101;2) SMOKE101=manager+employee 直属领导 SMOKE201;3) SMOKE201=ceo+employee;4) SMOKE301=finance+employee;5) SMOKE302=finance+finance_mgr+employee;6) SMOKE401=hr+employee;7) ~/.hyb-oa/live-smoke.env 权限 600 且不提交。 流程级验证:stat -c '%a %n' ~/.hyb-oa/live-smoke.env;远端 Prisma transaction 写入后只读复核 employeeNo/email/status/roles/directManager;node scripts/qa/check-live-smoke-env.mjs --strict。 ssh|node|manual DONE DONE DONE external|~/.hyb-oa/live-smoke.env ["S1"] S2 completed:因上一轮工具输出污染过生成密码,本轮已轮换全部 SMOKE 测试密码;~/.hyb-oa/live-smoke.env 权限 600,未提交、未打印密码;远端 Prisma transaction 已创建/更新 SMOKE001/SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302/SMOKE401,部门=管理部,法人=试运行法体;SMOKE002.directManager=SMOKE101,SMOKE101.directManager=SMOKE201,SMOKE201=ceo+employee,SMOKE301=finance+employee,SMOKE302=finance+finance_mgr+employee,SMOKE401=hr+employee;已撤销测试账号旧会话 23 个;check-live-smoke-env --strict 通过。回滚:删除新增 SMOKE101/SMOKE201/SMOKE301/SMOKE302/SMOKE401,SMOKE002.direct_manager_id 恢复 HYD000007/majianyong,按需要再次重置 SMOKE001/SMOKE002 密码;未部署、未迁移、未重启、未改远端 .env。
4 S3 更新 R5/O3 与真机清单口径 把 O3/R5 和真机验收清单从真实人员链路调整为测试替身账号链路,同时保留生产组织事实源说明。 1) 真机矩阵使用 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;2) SMOKE001 仅管理员不具备业务审批权;3) 企业微信测试成员缺失时记录 BLOCKED;4) 主 R5/O3 不因文档调整自动完成。 流程级验证:rg -n 'SMOKE101|SMOKE201|SMOKE301|SMOKE302|测试替身|企业微信测试成员|admin 不代表业务审批权' docs/trialrun/real-device-public-checklist.md issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv issues/2026-05-10_18-25-33-formal-approval-org-config.csv;git diff --check。 rg|manual DONE DONE DONE docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv ["S2"] S3 completed:真机清单、O3 合同和主 R5 Notes 已改为测试替身账号验收口径;生产正式组织事实源仍为 majianyong/wangshunli,但 R5/O3 验收使用 SMOKE002->SMOKE101->SMOKE201->SMOKE302/SMOKE301;SMOKE001 仅测试管理员,不代表业务审批权;若无企业微信测试成员,企业微信原生身份验收记 BLOCKED,R5/O3 不得 DONE。
5 S4 测试组织自动化验收 用测试组织账号执行公网自动化 live smoke、真实 OCR 和正式审批链自动化覆盖,验证不依赖真实员工。 1) run-live-smoke-readiness.sh 通过;2) 普通员工低/中额待办给 SMOKE101;3) 高额/例外链路进入 SMOKE101->SMOKE201 或按现有规则进入财务节点;4) SMOKE101 本人申请给 SMOKE201;5) SMOKE201 自审被阻断或进入非本人兜底;6) SMOKE001 不能仅凭 admin 审批;7) 财务/财务主管权限由 SMOKE301/SMOKE302 验证。 流程级验证:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;执行临时/仓库脚本提交请假/报销/采购并只读检查 workflowTask assignee;远端 audit-smoke-data 只读统计。 bash|node|ssh|playwright DONE DONE DONE scripts/qa/*|external ["S3"] S4 completed:run-live-smoke-readiness.sh 通过,证据 artifacts/qa-flow/live-smoke-readiness-20260510-205136,public-bundle findings=0,live smoke 3 passed,真实 OCR 样票不再 SKIP,终态 ocr_failed/重复发票可解释,pending OCR 因缺 DATABASE_URL 辅助 SKIP;临时正式测试组织链路脚本通过:SMOKE002 请假 214cb9d1-4b5a-4cef-9b3f-07243e9ea100 -> task 8a72bc3a-7773-4cc9-af3f-ce8b08c66a72,由 SMOKE101 可见;SMOKE001 admin-only 审批该任务被拒绝 403/PERM_002;SMOKE101 本人请假 af74a8e3-3df4-4a0b-b33d-8ab1f9116652 -> task da6cc325-4621-4992-a5a8-96ffd56b442a,由 SMOKE201 可见;SMOKE201 本人请假 81dcded2-e455-4568-9d25-9152f725ef33 提交被 409/VALID_001 阻断,details.workflow=no_actionable_approver reason=no_manager_found;小额报销 735095fa-dec1-492b-ab00-1d042a85d838 经 SMOKE101 审批后进入 finance role task 262f25d2-0203-470b-a4fc-fa4f81d62448,可由 SMOKE301 验证;大额报销 edfc6bbb-3210-433d-94fe-61eaacfb1eff 链路为 SMOKE101 task 177b3a9e-67fe-447d-92f4-96a652a06841 -> ceo role task 2b3a28d9-bfbb-42f8-8349-870d488577b6 -> finance_mgr role task 61af7b2f-5463-4c1c-97e9-b1c68dad6b63,可由 SMOKE302 验证;只读 smoke 审计 employees=7, authSessions=613, reimbursements=3, reimbursementItems=2, invoices=7, leaveRequests=4, workflowInstances=5, workflowTasksAssigned=4, workflowTasksCompleted=3, workflowHistory=8, notifications=6, auditLogs=280;本轮不清理测试数据,清理需另拆 destructive cleanup Issue。
6 S5 企业微信真机门禁与状态回填 使用企业微信测试成员绑定 OA 测试账号并完成真机矩阵;若测试成员缺失,记录 BLOCKED,不宣称 R5 通过。 1) 企业微信工作台测试成员可进入 OA;2) 移动首页、审批待办、报销详情、通知 deeplink 有截图/录屏位置;3) O3/R5 仅在真机和自动化均通过后 DONE;4) R6 仍等待 R5 完成。 流程级验证:按 docs/trialrun/real-device-public-checklist.md 用测试成员执行;校验新合同、O3 合同和主 R5 合同;git diff --check。 manual|python3 DOING TODO TODO docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv ["S4"] S5 BLOCKED:自动化和测试替身审批链已通过,但尚无企业微信侧测试成员绑定 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 的真机截图或录屏证据;因此 S5、O3、主 R5 继续保持未完成,不能宣称企业微信原生身份验收或 R6 小范围试运行已解锁。 S5 BLOCKED:自动化和测试替身审批链已通过,但尚无企业微信侧测试成员绑定 SMOKE002/SMOKE101/SMOKE201/SMOKE301/SMOKE302 的真机截图或录屏证据;因此 S5、O3、主 R5 继续保持未完成,不能宣称企业微信原生身份验收或 R6 小范围试运行已解锁。 S5 updated 2026-05-10:用户已用 iPhone 11/iOS14 与 Redmi K60/MIUI 最新企业微信真机确认 oa_smoke_01->SMOKE002、oa_smoke_02->SMOKE101 绑定通过,工作台进入 OA、已绑定免登录、SMOKE002 提交单据、SMOKE101 审批待办与审批详情/结果通过;当前 S5 仍 DOING/TODO/TODO,因为移动底部导航和报销详情深层页修复、PDF-only 发票上传需部署后复测,且 SMOKE201/SMOKE301/SMOKE302 负责人/财务替身企业微信真机矩阵仍待补齐。
@@ -0,0 +1,7 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
V1,R5 真机反馈记录,记录 oa_smoke_01/oa_smoke_02 企业微信真机绑定和审批链路通过结果;明确 R5 因移动底部导航与报销详情体验继续未完成。,1) 真机结果写入清单;2) S5/O3/R5 Notes 记录审批链通过但移动体验阻断;3) R5 不标 DONE;4) 不提交密码或截图敏感信息。,流程级验证:人工核对 docs/trialrun/real-device-public-checklist.md 与 S/O/R 合同 Notes;命令:rg -n 'oa_smoke_01|oa_smoke_02|iPhone 11|Redmi K60|移动体验阻断|R5 仍' docs/trialrun/real-device-public-checklist.md issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv issues/2026-05-10_18-25-33-formal-approval-org-config.csv issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv。,manual|rg,DONE,DONE,DONE,docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv,[],V1 completed:已记录 2026-05-10 企业微信真机结果:iPhone 11/iOS14、Redmi K60/MIUI 最新企业微信下 oa_smoke_01->SMOKE002 绑定通过、oa_smoke_02->SMOKE101 绑定通过、工作台进入 OA、已绑定免登录、SMOKE002 提交单据、SMOKE101 审批待办与审批详情/结果均通过;主 R5 仍未完成,阻断转为移动底部导航/报销详情深层页和 PDF-only 发票上传待部署复测。
V2,移动 Shell 与底部导航视口修复,修复 iOS 14 与企业微信 WebView 下移动页面高度和底部 Tab 固定问题;首页/审批/报销/我的首屏可见。,1) 底部导航固定在真实视口底部;2) 内容区预留导航高度;3) iPhone 11 和 Redmi K60 无横向溢出;4) 四个 Tab 文案不换行不挤压。,流程级验证:Playwright 企业微信 UA 下打开 /mobile 和 /mobile/reimbursements;断言 mobile bottom nav 可见且 bounding box 位于视口底部;断言 scrollWidth <= viewportWidth + 2;截图保存到 output/playwright/r5-mobile-viewport-pdf-invoice/。,playwright|npm,DONE,DONE,DONE,client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/styles/mobile-touch.css,"[""V1""]",V2 completed:AppShell 使用 visualViewport/innerHeight 维护 --mobile-viewport-height,底部 Tab fixed 到真实视口底部并预留内容区 padding;Playwright 覆盖 /mobile 与 /mobile/reimbursements 在 iPhone 11 iOS14、Redmi K60 视口下 bottom gap<=2、scrollWidth 无横向溢出、四 Tab 触控宽高>=44。
V3,报销深层页固定吸底操作,修复 /mobile/reimbursements/:id 深层页操作栏;隐藏底部 Tab 并固定顶部返回与底部主操作。,1) 详情页无底部 Tab;2) 顶部返回可见;3) 添加发票与提交审批在 iPhone 11 上同行等宽双按钮;4) 操作栏不遮挡内容且按钮高度不低于 44px。,流程级验证:Playwright 企业微信 UA 下打开 /mobile/reimbursements/:id;断言 mobile-nav-* 不存在;断言 mobile-reimbursement-sticky-actions 可见且两个主按钮同一行;检查无横向滚动和无遮挡。,playwright|npm,DONE,DONE,DONE,client/src/pages/MobileReimbursementDetailPage.tsx|client/tests/e2e/*.spec.ts,"[""V2""]",V3 completed:/mobile/reimbursements/:id 隐藏底部 Tab,顶部返回保留;吸底操作栏 fixed 到真实视口底部;定位到 page-scaffold 动画 transform 会破坏 fixed 视口定位,已对带吸底操作页禁用该 transform;添加发票/提交审批在 iPhone 11 和 Redmi K60 仍保持同行等宽双按钮,高度>=44。
V4,发票上传 PDF-only 收口,所有发票/OCR 上传入口只允许 PDF;移除拍照和图片上传入口;后端拒绝图片直传并返回明确错误。,1) PC 票据中心、PC 报销详情、移动票据上传、移动报销详情发票上传均只接受 PDF;2) 不存在 capture 和 image/* 发票入口;3) 后端拒绝 JPG/PNG/WEBP;4) 报销附件图片上传不回归。,流程级验证:cd server && node --check src/routes/invoicePool.js;前端静态搜索确认发票上传 accept 不含 image/*;Playwright 检查上传控件文案和 accept;后端单测或 API 测试覆盖 PDF 通过和图片拒绝。,node|playwright|npm,DONE,DONE,DONE,server/src/routes/invoicePool.js|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/invoice-pool/InvoiceUploadCard.tsx|client/src/pages/ReimbursementDetailPage.tsx,"[""V1""]",V4 completed:发票/OCR 上传入口统一 PDF-only,移动端去除拍照/图片/capture 文件入口,PC 票据中心和报销详情仅接受 application/pdf;后端 invoice-pool/upload 校验 MIME=application/pdf 且扩展名 .pdf,图片返回 VALID_001/pdf_invoice_required;附件材料图片上传保持不受影响。
V5,规约与真机清单同步,同步产品设计、报销发票规则和真机清单;删除发票拍照上传口径并记录本轮阻断结论。,1) 设计规则固定移动双按钮和 safe-area 口径;2) 报销发票规则明确 PDF-only;3) 真机清单更新 iPhone 11/Redmi K60 结果;4) 主 R5/O3/S5 状态不误标完成。,流程级验证:rg -n 'PDF-only|PDF 发票|iPhone 11|Redmi K60|底部导航|吸底|拍照上传' docs issues;git diff --check。,rg|manual,DONE,DONE,DONE,docs/product-design-rules.md|docs/reimbursement-invoice-rules.md|docs/trialrun/real-device-public-checklist.md|issues/*.csv,"[""V1"",""V2"",""V3"",""V4""]",V5 completed:docs/product-design-rules.md、docs/reimbursement-invoice-rules.md、docs/trialrun/real-device-public-checklist.md 及相关索引/清单已同步移动 safe-area、深层页吸底双按钮和 PDF-only 发票口径;不提交截图、样票、密码或敏感路径。
V6,本地回归与提交,执行本地后端、前端、视觉和 CSV 校验;通过后提交本轮修复但不宣称公网 R5 完成。,1) server prisma validate 和 node --check 通过;2) client typecheck/build 通过;3) 相关 E2E/视觉通过或记录阻断;4) CSV 校验通过;5) git diff --check 通过;6) 提交信息引用本合同。,流程级验证:cd server && npm run prisma:validate;cd server && node --check src/routes/invoicePool.js;cd client && npm run typecheck;cd client && npm run build;相关 Playwright;python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-10_22-44-06-r5-mobile-viewport-pdf-invoice.csv;git diff --check。,npm|node|python3|playwright|git,DONE,DONE,DONE,server/src/routes/invoicePool.js|client/src|docs|issues,"[""V5""]",V6 completed:已通过 CSV 校验、server prisma validate、node --check src/routes/invoicePool.js、server 单测 invoicePool.upload + reimbursements.attachments 9 passed、client typecheck/build、Playwright r5-real-device-feedback-mobile-ux + r111 + r124 共 5 passed、git diff --check。未部署;R5 仍需部署、R4 复检、公网 live smoke 与企业微信真机复测后才能完成。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 V1 R5 真机反馈记录 记录 oa_smoke_01/oa_smoke_02 企业微信真机绑定和审批链路通过结果;明确 R5 因移动底部导航与报销详情体验继续未完成。 1) 真机结果写入清单;2) S5/O3/R5 Notes 记录审批链通过但移动体验阻断;3) R5 不标 DONE;4) 不提交密码或截图敏感信息。 流程级验证:人工核对 docs/trialrun/real-device-public-checklist.md 与 S/O/R 合同 Notes;命令:rg -n 'oa_smoke_01|oa_smoke_02|iPhone 11|Redmi K60|移动体验阻断|R5 仍' docs/trialrun/real-device-public-checklist.md issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv issues/2026-05-10_18-25-33-formal-approval-org-config.csv issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv。 manual|rg DONE DONE DONE docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv|issues/2026-05-10_18-25-33-formal-approval-org-config.csv|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv [] V1 completed:已记录 2026-05-10 企业微信真机结果:iPhone 11/iOS14、Redmi K60/MIUI 最新企业微信下 oa_smoke_01->SMOKE002 绑定通过、oa_smoke_02->SMOKE101 绑定通过、工作台进入 OA、已绑定免登录、SMOKE002 提交单据、SMOKE101 审批待办与审批详情/结果均通过;主 R5 仍未完成,阻断转为移动底部导航/报销详情深层页和 PDF-only 发票上传待部署复测。
3 V2 移动 Shell 与底部导航视口修复 修复 iOS 14 与企业微信 WebView 下移动页面高度和底部 Tab 固定问题;首页/审批/报销/我的首屏可见。 1) 底部导航固定在真实视口底部;2) 内容区预留导航高度;3) iPhone 11 和 Redmi K60 无横向溢出;4) 四个 Tab 文案不换行不挤压。 流程级验证:Playwright 企业微信 UA 下打开 /mobile 和 /mobile/reimbursements;断言 mobile bottom nav 可见且 bounding box 位于视口底部;断言 scrollWidth <= viewportWidth + 2;截图保存到 output/playwright/r5-mobile-viewport-pdf-invoice/。 playwright|npm DONE DONE DONE client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/styles/mobile-touch.css ["V1"] V2 completed:AppShell 使用 visualViewport/innerHeight 维护 --mobile-viewport-height,底部 Tab fixed 到真实视口底部并预留内容区 padding;Playwright 覆盖 /mobile 与 /mobile/reimbursements 在 iPhone 11 iOS14、Redmi K60 视口下 bottom gap<=2、scrollWidth 无横向溢出、四 Tab 触控宽高>=44。
4 V3 报销深层页固定吸底操作 修复 /mobile/reimbursements/:id 深层页操作栏;隐藏底部 Tab 并固定顶部返回与底部主操作。 1) 详情页无底部 Tab;2) 顶部返回可见;3) 添加发票与提交审批在 iPhone 11 上同行等宽双按钮;4) 操作栏不遮挡内容且按钮高度不低于 44px。 流程级验证:Playwright 企业微信 UA 下打开 /mobile/reimbursements/:id;断言 mobile-nav-* 不存在;断言 mobile-reimbursement-sticky-actions 可见且两个主按钮同一行;检查无横向滚动和无遮挡。 playwright|npm DONE DONE DONE client/src/pages/MobileReimbursementDetailPage.tsx|client/tests/e2e/*.spec.ts ["V2"] V3 completed:/mobile/reimbursements/:id 隐藏底部 Tab,顶部返回保留;吸底操作栏 fixed 到真实视口底部;定位到 page-scaffold 动画 transform 会破坏 fixed 视口定位,已对带吸底操作页禁用该 transform;添加发票/提交审批在 iPhone 11 和 Redmi K60 仍保持同行等宽双按钮,高度>=44。
5 V4 发票上传 PDF-only 收口 所有发票/OCR 上传入口只允许 PDF;移除拍照和图片上传入口;后端拒绝图片直传并返回明确错误。 1) PC 票据中心、PC 报销详情、移动票据上传、移动报销详情发票上传均只接受 PDF;2) 不存在 capture 和 image/* 发票入口;3) 后端拒绝 JPG/PNG/WEBP;4) 报销附件图片上传不回归。 流程级验证:cd server && node --check src/routes/invoicePool.js;前端静态搜索确认发票上传 accept 不含 image/*;Playwright 检查上传控件文案和 accept;后端单测或 API 测试覆盖 PDF 通过和图片拒绝。 node|playwright|npm DONE DONE DONE server/src/routes/invoicePool.js|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/invoice-pool/InvoiceUploadCard.tsx|client/src/pages/ReimbursementDetailPage.tsx ["V1"] V4 completed:发票/OCR 上传入口统一 PDF-only,移动端去除拍照/图片/capture 文件入口,PC 票据中心和报销详情仅接受 application/pdf;后端 invoice-pool/upload 校验 MIME=application/pdf 且扩展名 .pdf,图片返回 VALID_001/pdf_invoice_required;附件材料图片上传保持不受影响。
6 V5 规约与真机清单同步 同步产品设计、报销发票规则和真机清单;删除发票拍照上传口径并记录本轮阻断结论。 1) 设计规则固定移动双按钮和 safe-area 口径;2) 报销发票规则明确 PDF-only;3) 真机清单更新 iPhone 11/Redmi K60 结果;4) 主 R5/O3/S5 状态不误标完成。 流程级验证:rg -n 'PDF-only|PDF 发票|iPhone 11|Redmi K60|底部导航|吸底|拍照上传' docs issues;git diff --check。 rg|manual DONE DONE DONE docs/product-design-rules.md|docs/reimbursement-invoice-rules.md|docs/trialrun/real-device-public-checklist.md|issues/*.csv ["V1","V2","V3","V4"] V5 completed:docs/product-design-rules.md、docs/reimbursement-invoice-rules.md、docs/trialrun/real-device-public-checklist.md 及相关索引/清单已同步移动 safe-area、深层页吸底双按钮和 PDF-only 发票口径;不提交截图、样票、密码或敏感路径。
7 V6 本地回归与提交 执行本地后端、前端、视觉和 CSV 校验;通过后提交本轮修复但不宣称公网 R5 完成。 1) server prisma validate 和 node --check 通过;2) client typecheck/build 通过;3) 相关 E2E/视觉通过或记录阻断;4) CSV 校验通过;5) git diff --check 通过;6) 提交信息引用本合同。 流程级验证:cd server && npm run prisma:validate;cd server && node --check src/routes/invoicePool.js;cd client && npm run typecheck;cd client && npm run build;相关 Playwright;python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-10_22-44-06-r5-mobile-viewport-pdf-invoice.csv;git diff --check。 npm|node|python3|playwright|git DONE DONE DONE server/src/routes/invoicePool.js|client/src|docs|issues ["V5"] V6 completed:已通过 CSV 校验、server prisma validate、node --check src/routes/invoicePool.js、server 单测 invoicePool.upload + reimbursements.attachments 9 passed、client typecheck/build、Playwright r5-real-device-feedback-mobile-ux + r111 + r124 共 5 passed、git diff --check。未部署;R5 仍需部署、R4 复检、公网 live smoke 与企业微信真机复测后才能完成。
+30 -3
查看文件
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { existsSync, readdirSync, statSync } from "node:fs";
import { extname } from "node:path";
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
@@ -16,6 +17,28 @@ function existingFileFrom(keys) {
return values.find((value) => existsSync(value)) || "";
}
function isPdfFile(value) {
if (!value || !existsSync(value)) {
return false;
}
try {
return statSync(value).isFile() && extname(value).toLowerCase() === ".pdf";
} catch {
return false;
}
}
function hasPdfFileInDir(value) {
if (!value || !existsSync(value)) {
return false;
}
try {
return statSync(value).isDirectory() && readdirSync(value).some((entry) => extname(entry).toLowerCase() === ".pdf");
} catch {
return false;
}
}
async function fetchJson(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
@@ -46,7 +69,9 @@ function buildStaticChecks() {
);
const invoiceFile = existingFileFrom(["PLAYWRIGHT_OCR_LIVE_FILE", "LIVE_OCR_INVOICE_FILE"]);
const invoiceBatchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() || "";
const invoiceSampleReady = Boolean(invoiceFile) || (Boolean(invoiceBatchDir) && existsSync(invoiceBatchDir));
const invoiceFileReady = isPdfFile(invoiceFile);
const invoiceBatchDirReady = hasPdfFileInDir(invoiceBatchDir);
const invoiceSampleReady = invoiceFileReady || invoiceBatchDirReady;
return {
baseUrl,
@@ -64,7 +89,9 @@ function buildStaticChecks() {
userReady: ocrUserReady,
invoiceSampleReady,
invoiceFileProvided: Boolean(invoiceFile),
invoiceFileReady,
invoiceBatchDirProvided: Boolean(invoiceBatchDir),
invoiceBatchDirReady,
allowDuplicate: process.env.PLAYWRIGHT_OCR_ALLOW_DUPLICATE === "1",
},
missing: [],
@@ -82,7 +109,7 @@ function addMissing(checks) {
checks.missing.push("PLAYWRIGHT_OCR_LIVE_IDENTIFIER/PASSWORD 或 LIVE_SMOKE_EMPLOYEE_*");
}
if (!checks.liveOcr.invoiceSampleReady) {
checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR");
checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR 中的 PDF 样票");
}
}
+2 -3
查看文件
@@ -29,9 +29,8 @@ const checklist = [
"移动首页无 PC 侧边栏或桌面表格",
"报销草稿可新建、编辑、删除",
"报销详情不出现 draft/bound/费用类型 code/绑定整单/发票池/OCR",
"拍照上传打开摄像头",
"选择图片支持 JPG/PNG/WEBP",
"选择 PDF 不打开摄像头并可选中文件",
"PDF 发票上传入口不打开摄像头并可选中文件",
"发票上传不出现拍照或图片入口",
"真实发票上传后有识别中、终态和耗时记录",
"重复、已使用或识别失败发票显示可理解原因",
"审批待办可查看并操作",
+8 -9
查看文件
@@ -23,12 +23,7 @@ const {
resolveUploadDedupDecision,
} = require("../services/invoiceGovernance");
const MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
const INVOICE_UPLOAD_MIME_TYPE = "application/pdf";
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.union([
@@ -200,6 +195,10 @@ function parseDateOnlyString(value) {
return new Date(`${trimmed}T00:00:00.000Z`);
}
function isPdfInvoiceUpload(fileName, mimeType) {
return mimeType === INVOICE_UPLOAD_MIME_TYPE && path.extname(fileName ?? "").toLowerCase() === ".pdf";
}
async function resolveDefaultExpenseType(tx) {
const preferred = await tx.expenseType.findFirst({
where: {
@@ -369,12 +368,12 @@ async function invoicePoolRoutes(fastify) {
fileName = part.filename ?? "upload";
fileMimeType = part.mimetype;
if (!MIME_ALLOWLIST.has(fileMimeType)) {
if (!isPdfInvoiceUpload(fileName, fileMimeType)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
message: "请上传 PDF 发票文件",
statusCode: 400,
details: { file: "unsupported_mime_type" },
details: { file: "pdf_invoice_required" },
});
}
@@ -193,9 +193,9 @@ describe("invoicePool upload route", () => {
try {
const requestPayload = buildMultipartRequest({
filename: "invoice.png",
contentType: "image/png",
content: "mock-image",
filename: "invoice.pdf",
contentType: "application/pdf",
content: "%PDF-1.4 mock invoice",
fields: [["ocr_type", "vat"]],
});
@@ -234,6 +234,43 @@ describe("invoicePool upload route", () => {
}
});
it("rejects image invoice uploads because formal invoice OCR only accepts PDF", async () => {
const { app, prisma, tempDir } = await buildApp();
try {
const requestPayload = buildMultipartRequest({
filename: "invoice.png",
contentType: "image/png",
content: "mock-image",
fields: [["ocr_type", "vat"]],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/upload",
headers: {
authorization: "Bearer test",
...requestPayload.headers,
},
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toEqual(
expect.objectContaining({
code: "VALID_001",
message: "请上传 PDF 发票文件",
details: { file: "pdf_invoice_required" },
}),
);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("reuses same-owner existing invoice instead of creating a duplicate file record", async () => {
const existingInvoiceId = "22222222-2222-4222-8222-222222222222";
const { app, prisma, tempDir } = await buildApp({