修复 R7E 真机反馈回流与移动入口 WF8-11A R7E-16A R7E-16B BIZ8-12A
这个提交包含在:
@@ -34,7 +34,7 @@ Default behavior:
|
||||
## Delivery Loop
|
||||
|
||||
1. Check `git status --short`; do not revert unrelated user changes.
|
||||
2. Identify or create the current Issue CSV before non-trivial work; after R5, distinguish R6 preparation from actual trial-run execution.
|
||||
2. Identify the current active continuous Issue CSV before non-trivial work. Same-type trial-run feedback, real-device feedback, single-surface defects, and small follow-up fixes must append rows or update Notes in the active flow instead of creating one-off CSVs. Create a new CSV only when no active same-type flow exists, the current flow is closed, the change crosses risk/business domains, or the user explicitly asks for a new contract; if creating one, state why the active flows cannot carry it. After R5, distinguish R6 preparation from actual trial-run execution.
|
||||
3. State the implementation approach before editing code; if already approved, proceed.
|
||||
4. Keep changes scoped to the current Issue.
|
||||
5. Prefer existing project patterns, official SDKs, and the main UI/layout hierarchy in `docs/产品设计规则.md`; for page work, classify the page into DG9 A/B/C layering and reuse the matching workbench/mobile sample before adding local layout code. Do not mix multiple competitor visual systems or copy competitor flows/text directly.
|
||||
|
||||
@@ -83,6 +83,7 @@ export type PortalHomePayload = {
|
||||
pendingBatchCount: number;
|
||||
failedItemCount: number;
|
||||
riskCount: number;
|
||||
paymentBatchEnabled?: boolean;
|
||||
href: string;
|
||||
} | null;
|
||||
adoptionSummary:
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
UserCog,
|
||||
UserRound,
|
||||
Users,
|
||||
WalletCards,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
account_tree: Network,
|
||||
account_balance_wallet: WalletCards,
|
||||
badge: IdCard,
|
||||
cloud_upload: CloudUpload,
|
||||
event_available: CalendarCheck,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const PDF_FILE_ACCEPT = ".pdf,application/pdf";
|
||||
|
||||
export function isPdfFile(file: File) {
|
||||
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
export function splitPdfFiles(files: File[] | FileList | null | undefined) {
|
||||
const source = Array.from(files ?? []);
|
||||
return {
|
||||
accepted: source.filter(isPdfFile),
|
||||
rejected: source.filter((file) => !isPdfFile(file)),
|
||||
};
|
||||
}
|
||||
|
||||
export function rejectedPdfMessage(rejected: File[]) {
|
||||
if (!rejected.length) {
|
||||
return "";
|
||||
}
|
||||
const names = rejected.slice(0, 3).map((file) => file.name || "未命名文件").join("、");
|
||||
const suffix = rejected.length > 3 ? `等 ${rejected.length} 个文件` : "";
|
||||
return `发票上传仅支持 PDF,已忽略 ${names}${suffix}。`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Spinner } from "../components/ui/spinner";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import {
|
||||
enqueueInvoiceUpload,
|
||||
@@ -145,6 +146,17 @@ export function MobileCapturePage() {
|
||||
|
||||
const queuedItems = queueQuery.data ?? [];
|
||||
|
||||
function handlePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedFiles(accepted);
|
||||
const rejectMessage = rejectedPdfMessage(rejected);
|
||||
setError(rejectMessage);
|
||||
setMessage("");
|
||||
if (rejectMessage) {
|
||||
toast.warning(rejectMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const queuedCountLabel = useMemo(() => {
|
||||
if (!queuedItems.length) return "无待上传项";
|
||||
return `待上传 ${queuedItems.length} 项`;
|
||||
@@ -210,12 +222,13 @@ export function MobileCapturePage() {
|
||||
</label>
|
||||
<input
|
||||
id="mobile-capture-pdf-file"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
className="sr-only"
|
||||
data-testid="mobile-capture-pdf-file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(Array.from(event.target.files ?? []));
|
||||
handlePdfSelection(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchEmployeeProfile } from "../api/employees";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
@@ -65,6 +65,7 @@ export function MobileMePage() {
|
||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
const canAccessFinance = hasFinanceRole(me?.roleCodes, permissions);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(me?.id),
|
||||
@@ -128,6 +129,7 @@ export function MobileMePage() {
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
|
||||
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
|
||||
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与台账" icon="account_balance_wallet" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
</div>
|
||||
|
||||
@@ -257,6 +257,7 @@ export function MobilePortalPage() {
|
||||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||
.slice(0, 3);
|
||||
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
||||
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
|
||||
|
||||
async function openHomeNotification(item: NotificationItem) {
|
||||
const targetLink = resolveMobileNotificationTargetLink(item);
|
||||
@@ -378,10 +379,10 @@ export function MobilePortalPage() {
|
||||
) : null}
|
||||
|
||||
{canUseFinance ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<section id="finance" className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">财务</div>
|
||||
<span className="badge-neutral">复核与付款</span>
|
||||
<span className="badge-neutral">{paymentBatchEnabled ? "复核与付款" : "审批与台账"}</span>
|
||||
</div>
|
||||
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
|
||||
{portalHomeQuery.isError ? (
|
||||
@@ -389,22 +390,30 @@ export function MobilePortalPage() {
|
||||
{resolveErrorMessage(portalHomeQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mobile-metric-grid text-sm">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">待付款</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeSummary?.pendingBatchCount ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">异常</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">
|
||||
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
|
||||
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && paymentBatchEnabled ? (
|
||||
<div className="mobile-metric-grid text-sm">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">待付款</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeSummary?.pendingBatchCount ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">异常</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">
|
||||
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">台账/导出</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">台账/导出</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
|
||||
) : null}
|
||||
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && !paymentBatchEnabled ? (
|
||||
<div className="mobile-subcard text-sm text-zinc-600" data-testid="mobile-finance-disabled-state">
|
||||
<div className="font-medium text-zinc-900">付款批次未启用</div>
|
||||
<div className="mt-1 leading-5">可查看已流转到财务审批的报销单,待付款和异常批次暂不展示。</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
formatInvoiceDisplayTitle,
|
||||
@@ -224,6 +225,16 @@ export function MobileReimbursementDetailPage() {
|
||||
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedInvoiceFiles(accepted);
|
||||
const message = rejectedPdfMessage(rejected);
|
||||
setInvoiceActionError(message);
|
||||
if (message) {
|
||||
toast.warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
const bindableInvoicesQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
|
||||
queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch],
|
||||
@@ -1266,9 +1277,12 @@ export function MobileReimbursementDetailPage() {
|
||||
className="sr-only"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
onChange={(event) => {
|
||||
handleInvoicePdfSelection(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedInvoiceFiles.length ? (
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
ReimbursementItemEditor,
|
||||
@@ -806,6 +807,16 @@ export function ReimbursementDetailPage() {
|
||||
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedInvoiceFiles(accepted);
|
||||
const message = rejectedPdfMessage(rejected);
|
||||
setActionError(message);
|
||||
if (message) {
|
||||
toast.warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||
|
||||
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
|
||||
@@ -1730,9 +1741,12 @@ export function ReimbursementDetailPage() {
|
||||
className="hidden"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
onChange={(event) => {
|
||||
handleInvoicePdfSelection(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchMyDelegationsResponse } from "../../api/delegations";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { PDF_FILE_ACCEPT, splitPdfFiles } from "../../lib/pdfFiles";
|
||||
|
||||
export type InvoiceUploadCardProps = {
|
||||
selectedFiles: File[];
|
||||
@@ -62,10 +63,13 @@ export function InvoiceUploadCard({
|
||||
<input
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
data-testid="invoice-upload-file"
|
||||
multiple
|
||||
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(splitPdfFiles(event.target.files).accepted);
|
||||
event.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
|
||||
@@ -253,8 +253,8 @@
|
||||
|
||||
.mobile-list-card__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -281,10 +281,17 @@
|
||||
}
|
||||
|
||||
.mobile-status-slot {
|
||||
flex: none;
|
||||
max-width: 42%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
min-width: max-content;
|
||||
max-width: min(46vw, 8rem);
|
||||
justify-self: end;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-status-slot > * {
|
||||
min-width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -542,6 +542,7 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
|
||||
pendingBatchCount: 2,
|
||||
failedItemCount: 1,
|
||||
riskCount: 3,
|
||||
paymentBatchEnabled: false,
|
||||
href: "/ap-operations",
|
||||
}
|
||||
: null,
|
||||
@@ -765,11 +766,16 @@ async function expectNoMobileShellRegression(page: Page) {
|
||||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||||
visibleTables: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
|
||||
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
|
||||
clippedStatusSlots: Array.from(document.querySelectorAll<HTMLElement>(".mobile-status-slot"))
|
||||
.filter(isVisible)
|
||||
.filter((slot) => slot.scrollWidth > slot.clientWidth + 1)
|
||||
.map((slot) => slot.innerText.trim()),
|
||||
};
|
||||
});
|
||||
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
|
||||
expect(metrics.visibleTables).toBe(0);
|
||||
expect(metrics.pcShellGridCount).toBe(0);
|
||||
expect(metrics.clippedStatusSlots).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectActionTileGapStable(page: Page) {
|
||||
@@ -1038,10 +1044,13 @@ test.describe("MIF7 移动信息架构整改", () => {
|
||||
await loginForMobile(financeMobile.page, "finance");
|
||||
await financeMobile.page.goto("/mobile");
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toBeVisible();
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("待付款");
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("台账/导出");
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("付款批次未启用");
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-disabled-state")).toBeVisible();
|
||||
await expectNoMobileShellRegression(financeMobile.page);
|
||||
await financeMobile.page.screenshot({ path: screenshotPath("home-finance-summary"), fullPage: true });
|
||||
await financeMobile.page.goto("/mobile/me");
|
||||
await expect(financeMobile.page.getByRole("link", { name: /财务/ })).toBeVisible();
|
||||
await expectNoMobileShellRegression(financeMobile.page);
|
||||
} finally {
|
||||
await financeMobile.context.close();
|
||||
}
|
||||
@@ -1052,6 +1061,8 @@ test.describe("MIF7 移动信息架构整改", () => {
|
||||
await loginForMobile(adminMobile.page, "admin");
|
||||
await adminMobile.page.goto("/mobile");
|
||||
await expect(adminMobile.page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
||||
await adminMobile.page.goto("/mobile/me");
|
||||
await expect(adminMobile.page.getByRole("link", { name: /财务/ })).toHaveCount(0);
|
||||
} finally {
|
||||
await adminMobile.context.close();
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ test.describe("R105 pretrial visual acceptance", () => {
|
||||
await expect(page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible();
|
||||
const fileInput = page.getByTestId("mobile-capture-pdf-file");
|
||||
await expect(fileInput).toHaveAttribute("multiple", "");
|
||||
await expect(fileInput).toHaveAttribute("accept", "application/pdf");
|
||||
await expect(fileInput).toHaveAttribute("accept", ".pdf,application/pdf");
|
||||
await expect(fileInput).not.toHaveAttribute("capture", /.+/);
|
||||
await expect(page.getByTestId("mobile-capture-upload")).toBeVisible();
|
||||
await expectNoDesktopTableOnMobile(page);
|
||||
|
||||
@@ -146,7 +146,7 @@ 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-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")).toHaveAttribute("accept", ".pdf,application/pdf");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
|
||||
await expectNoPcShell(mobile.page);
|
||||
|
||||
|
||||
@@ -609,7 +609,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await invoiceDialog.getByRole("button", { name: "上传" }).click();
|
||||
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")).toHaveAttribute("accept", ".pdf,application/pdf");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).not.toHaveAttribute("capture", /.+/);
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||
|
||||
@@ -454,6 +454,10 @@ async function collectMobileMetrics(page: Page) {
|
||||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||||
tableCount: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
|
||||
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
|
||||
clippedStatusSlots: Array.from(document.querySelectorAll<HTMLElement>(".mobile-status-slot"))
|
||||
.filter(isVisible)
|
||||
.filter((slot) => slot.scrollWidth > slot.clientWidth + 1)
|
||||
.map((slot) => slot.innerText.trim()),
|
||||
text: document.body?.innerText ?? "",
|
||||
};
|
||||
});
|
||||
@@ -465,6 +469,7 @@ async function expectMobilePageContract(page: Page) {
|
||||
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
|
||||
expect(metrics.tableCount).toBe(0);
|
||||
expect(metrics.pcShellGridCount).toBe(0);
|
||||
expect(metrics.clippedStatusSlots).toEqual([]);
|
||||
expect(metrics.text).not.toContain("返回仪表盘");
|
||||
}
|
||||
|
||||
@@ -493,8 +498,14 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
||||
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-pdf-file")).toHaveAttribute("accept", "application/pdf");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", ".pdf,application/pdf");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
|
||||
await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({
|
||||
name: "camera.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
buffer: Buffer.from("r124-image"),
|
||||
});
|
||||
await expect(mobile.page.getByRole("main").getByText(/发票上传仅支持 PDF,已忽略 camera.jpg/)).toBeVisible();
|
||||
await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({
|
||||
name: "r124-invoice.pdf",
|
||||
mimeType: "application/pdf",
|
||||
|
||||
@@ -223,6 +223,20 @@ R7E 结论:公网、live smoke/OCR、移动矩阵、核心业务流、账号
|
||||
|
||||
阶段结论:无人工自动化已完成公网健康、报销提交审批、请假、采购附件、审批边界、移动浏览器、企业微信 UA 模拟和本地移动矩阵前置修正。`R7E-10`、`R7E-11`、`R7E-12`、`R7E-13` 可按浏览器业务线证据关闭;`R7E-14` 因公网 AP/payment-batches 功能门禁 `flag_force_off` 保持 `DOING`;`R7E-9`、`R7E-15`、`R7E-18` 仍需真实企业微信和人工验收,不由本轮自动化替代。
|
||||
|
||||
## R7E 真机反馈修复记录
|
||||
|
||||
执行时间:2026-05-14。用户已完成代表设备人工反馈,本轮只在本地代码和现有 Issue 流中处理,不自动部署。
|
||||
|
||||
| 反馈项 | 级别 | 归属 | 本轮处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 所有设备可企业微信免登录进入 OA | PASS | `R7E-9` | 记录为真机通过项 |
|
||||
| 企业微信消息点击均提示 `requestid tokenexpired` 或深链已过期 | HIGH | `WF8-11A` | 修复签名 deeplink 过期回流;企业微信 UA 走免登录 returnTo |
|
||||
| iOS 只能上传 PDF,Android 选择器仍显示拍照和选择文件 | MEDIUM | `R7E-16B` | OA 前端增加 PDF-only 过滤;Android 系统选择器表现记录为 WebView 限制 |
|
||||
| 通知卡片和待审批卡片右侧状态只显示首字 | MEDIUM | `R7E-16A` | 修复移动状态槽样式并加入 Playwright 遮挡断言 |
|
||||
| 财务移动入口和付款未启用状态不清楚 | MEDIUM | `BIZ8-12A` | 增加 `/mobile/me` 财务入口;付款批次关闭时显示未启用空态 |
|
||||
|
||||
部署状态:本节对应修复尚未部署到 `https://oa.hyinda.com`。用户真机复测需等待后续受控部署和公网 `/ready`、public bundle、移动矩阵检查通过。
|
||||
|
||||
## QAOPS8 Go/No-Go 当前结论
|
||||
|
||||
执行时间:2026-05-13。
|
||||
|
||||
@@ -8,14 +8,16 @@ R7E-5,问题登记负责人确认,"确认 R7E 问题登记负责人、问题分
|
||||
R7E-6,每日收口节奏确认,"确认 R7E 每日收口节奏,包括当天问题、阻断判定、是否继续、是否回滚和次日计划。","1) 每日收口模板确认;2) 阻断问题处理规则明确;3) 是否继续/暂停/回滚有决策人;4) 收口证据可追溯。","流程级验证:人工确认每日收口模板;首日试运行后按模板记录。",manual,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""R7E-5""]",每日 17:30 收口;记录当天流程结果 问题级别 继续或暂停或回滚判断 次日动作和证据路径;不在仓库记录密码 token 私密联系方式或未授权票据原件。
|
||||
R7E-7,暂停和回滚决策人确认,"确认 R7E 试运行中触发暂停、回滚或继续推进的最终决策人。","1) 决策人角色确认;2) 决策条件确认;3) 与回滚联系人不冲突;4) 未确认不得执行真实 R7。","流程级验证:人工确认暂停/回滚/继续决策人。",manual,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""R7E-4"",""R7E-6""]",最终决策人为 wangshunli;触发条件包括 P0 阻断 权限泄漏 财务数据错误 审批链错误 无法登录或无法回 OA 或两个以上高优先级问题当天无法解释清楚;R7E-8 可进入。
|
||||
R7E-8,公网健康复核,"在真实 R7E 启动当天复核公网匿名健康和 public bundle。","1) /ready 返回 ok=true;2) public bundle findings=0;3) 结果写入试运行报告;4) 失败则暂停试运行。","流程级验证:curl -fsS https://oa.hyinda.com/ready;PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs。",curl|node,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""R7E-7""]",2026-05-14 按最新代码 4b14c92 完成受控部署;部署前 client typecheck/build 通过;部署中发现既有 BIZ8 加性 migration 未应用 先创建 DB 备份 hyb-oa-db-20260514_011940.dump 和附件备份 hyb-oa-storage-20260514_011940.tar.gz 后执行 prisma migrate deploy;复跑 deploy.sh 通过且远端 migration up to date;公网 /ready ok=true;public bundle scanned=57 focused=4 findings=0;公网移动矩阵 dg7-mobile-information-dedupe 5 passed。
|
||||
R7E-9,企业微信入口复核,"在真实 R7E 启动前复核企业微信入口、绑定状态、免登或账号密码兜底、deeplink 回 OA。","1) 企业微信入口可达;2) 绑定用户可进入移动端;3) 未绑定有明确兜底;4) deeplink 回 OA 正确。","流程级验证:代表性真机或企业微信 WebView 人工复核;必要时补 Playwright WeCom UA。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-8""]",2026-05-14 无人工自动化补测:WeCom UA 模拟通过登录入口 企业微信配置和 OAuth 边界可达;证据 artifacts/r7e/browser-business-trialrun/report-wecom-ua.md;真实企业微信免登录 消息送达 消息 deeplink WebView 文件选择器和代表设备反馈仍需人工确认,故本行不提前 DONE。
|
||||
R7E-9,企业微信入口复核,"在真实 R7E 启动前复核企业微信入口、绑定状态、免登或账号密码兜底、deeplink 回 OA。","1) 企业微信入口可达;2) 绑定用户可进入移动端;3) 未绑定有明确兜底;4) deeplink 回 OA 正确。","流程级验证:代表性真机或企业微信 WebView 人工复核;必要时补 Playwright WeCom UA。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-8""]",2026-05-14 自动化 WeCom UA 模拟通过登录入口 企业微信配置和 OAuth 边界可达;用户真机确认所有设备可企业微信免登录进入 OA 但个人应用消息点击均提示 requestid tokenexpired 或深链已过期 归属 WF8-11A;消息送达 deeplink WebView 文件选择器和代表设备反馈仍需修复后人工确认 故本行不提前 DONE。
|
||||
R7E-10,报销流程试运行,"员工按真实路径提交报销或复核已有样例,覆盖发票/OCR/附件/审批/财务查看。","1) 员工可提交或查看报销;2) OCR/PDF-only 口径清晰;3) 审批链可追溯;4) 财务可复核;5) 问题进入登记清单。","流程级验证:员工进入报销 -> 上传/选择材料 -> 提交 -> 审批 -> 财务复核;记录结果和问题。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:员工创建并提交报销 40876ef3-0652-4972-aa14-1aad1a855ae0 / BX-20260514-0016;授权 PDF 样票上传成功并绑定票据 188b076e-07cc-4058-aac9-02b5161a188a;审批人处理通过后申请人回看状态 approved 和审批历史;复用该单进入财务验证时 AP 台账筛选为空,财务付款闭环另记 R7E-14/R7E-16;证据 artifacts/r7e/browser-business-trialrun/report-reimbursement-finance.md 和 output/playwright/r7e-browser-business-trialrun。
|
||||
R7E-11,审批流程试运行,"审批人按真实路径处理待办,员工查看进度,验证通知和审批历史一致。","1) 审批人可处理待办;2) 申请人可看进度;3) 审批历史一致;4) 自审和越权被拒绝。","流程级验证:提交申请 -> 审批人处理 -> 申请人查看状态 -> 审计/历史可追溯。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:报销 BX-20260514-0016 请假 a22e45f8-b3d6-4862-97f2-30cbe4815e09 采购 CG-20260514-0005 均完成真实浏览器或 API 待办审批并可申请人回看状态和历史;补跑 approval-boundary 创建请假 b0cd3e00-2e67-4f51-9aaf-63aa2c53b47e / task 2cf30190-f569-4cce-ac9e-a0fe183bd460,申请人本人和 admin-only 审批接口均被 PERM_002 拒绝,经理正常通过;server workflow 单元测试 2 files 14 tests passed;证据 report-approval-boundary.md。
|
||||
R7E-12,请假流程试运行,"员工提交请假,审批人审批,员工和团队视角查看状态。","1) 请假提交成功;2) 审批处理成功;3) 移动/PC 状态一致;4) 团队状态或日历口径可解释。","流程级验证:员工提交请假 -> 审批通过/驳回 -> 查看进度和团队状态。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:员工创建未来工作日请假并提交;审批人处理通过;申请人 PC 回看已通过且 /mobile/leave 记录页可见无横向滚动;测试单据 a22e45f8-b3d6-4862-97f2-30cbe4815e09;证据 artifacts/r7e/browser-business-trialrun/report-leave.md。
|
||||
R7E-13,采购流程试运行,"员工提交或查看采购申请,审批人处理,状态和附件可追溯。","1) 采购申请路径可用;2) 审批处理可用;3) 附件和状态可追溯;4) 移动采购创建如缺失则记录为后续 UX/P2。","流程级验证:员工提交/查看采购 -> 审批处理 -> 查看状态和附件。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:采购 CG-20260514-0005 完成 PC 创建 明细 无敏感测试附件上传 提交 审批 状态和审批历史回看;移动采购列表 详情和附件可见且无横向滚动;证据 artifacts/r7e/browser-business-trialrun/report-purchase-attachment.md。
|
||||
R7E-14,财务付款和导出试运行,"财务按真实路径复核报销、标记付款、查看台账或导出。","1) 财务角色入口正确;2) 付款标记或台账查看可用;3) 导出权限边界清楚;4) 管理员不自动获得财务能力。","流程级验证:财务进入台账 -> 复核/付款/导出 -> 检查权限和审计。",manual|playwright,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 finance-closeout 诊断:报销 40876ef3-0652-4972-aa14-1aad1a855ae0 / BX-20260514-0016 状态 approved;/api/ap-operations/reimbursement-ledger 和 /api/payment-batches 均返回 payment_batch_v1_disabled / flag_force_off;未创建付款批次 未复核 未执行付款 未验证导出;本行保持 DOING 并分流 R7E-16,待公网功能门禁开启或财务路径确认后复测。
|
||||
R7E-15,移动端和真机反馈,"收集 R7E 参与者在移动浏览器和企业微信中的真实体验反馈。","1) 至少覆盖代表性 iPhone/Android 或企业微信设备;2) 记录页面、视口、角色和问题级别;3) 阻断问题进入修复分流;4) 体验优化进入 UX8。","流程级验证:按真机公网测试清单人工复核;必要时补 Playwright 截图。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-9""]",2026-05-14 无人工自动化补测:移动浏览器员工 审批人 财务核心入口通过且无横向滚动;证据 artifacts/r7e/browser-business-trialrun/report-mobile.md;企业微信 UA 模拟通过但不能替代代表性 iPhone/Android 企业微信真机反馈,故本行不提前 DONE。
|
||||
R7E-16,每日问题清单,"R7E 期间每天登记问题、级别、归属治理流、处理状态和证据路径。","1) 问题均有级别;2) 阻断问题当天决策;3) 新需求不混入即时修复;4) 每日 closeout 可追溯。","流程级验证:每日人工 closeout;问题分流到 R7E/UX8/ORG8/WF8/BIZ8/QAOPS8。",manual,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10"",""R7E-11"",""R7E-12"",""R7E-13"",""R7E-14"",""R7E-15""]",2026-05-14 无人工自动化待分流项:财务付款闭环暂停,原因已收敛为公网 AP/payment-batches 功能门禁 payment_batch_v1_disabled / flag_force_off;R7E-11 live 申请人/admin-only 越权边界已补齐并关闭;真实企业微信免登录 消息 deeplink WebView 文件选择器和代表设备反馈仍需人工;dg7-mobile-information-dedupe 启动本地 Vite 后 5 passed,之前 127.0.0.1:3000 失败确认为前置服务未启动;问题记录不得包含密码 token 或隐私明文。
|
||||
R7E-15,移动端和真机反馈,"收集 R7E 参与者在移动浏览器和企业微信中的真实体验反馈。","1) 至少覆盖代表性 iPhone/Android 或企业微信设备;2) 记录页面、视口、角色和问题级别;3) 阻断问题进入修复分流;4) 体验优化进入 UX8。","流程级验证:按真机公网测试清单人工复核;必要时补 Playwright 截图。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-9""]",2026-05-14 自动化移动浏览器员工 审批人 财务核心入口通过且无横向滚动;用户真机补充:所有设备没有 PC 壳和横向滚动 但通知卡片和待审批卡片右侧状态只露第一个字 归属 R7E-16A;iOS 只能上传 PDF Android 企业微信选择器仍显示拍照和选择文件 归属 R7E-16B;企业微信 UA 模拟不能替代修复后的 iPhone/Android 真机复测。
|
||||
R7E-16,每日问题清单,"R7E 期间每天登记问题、级别、归属治理流、处理状态和证据路径。","1) 问题均有级别;2) 阻断问题当天决策;3) 新需求不混入即时修复;4) 每日 closeout 可追溯。","流程级验证:每日人工 closeout;问题分流到 R7E/UX8/ORG8/WF8/BIZ8/QAOPS8。",manual,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10"",""R7E-11"",""R7E-12"",""R7E-13"",""R7E-14"",""R7E-15""]",2026-05-14 用户真机反馈分流:免登录通过;企业微信消息 deeplink 全设备提示 requestid tokenexpired/深链已过期 为 HIGH 归属 WF8-11A;通知和待审批卡片右侧状态只露首字 为 MEDIUM 归属 R7E-16A;Android 企业微信 PDF 上传系统选择器仍显示拍照和选择文件 为 MEDIUM 归属 R7E-16B;财务移动入口和付款未启用空态不清楚 为 MEDIUM 归属 BIZ8-12A;财务付款闭环仍因 payment_batch_v1_disabled / flag_force_off 保持 R7E-14 DOING。
|
||||
R7E-16A,移动通知和待审批状态槽修复,"修复移动通知卡片和待审批卡片右侧状态徽标只显示第一个字的问题,保持无 PC 壳和无横向滚动。","1) 未读 已读 审批中 已处理等状态在 320/360/390/430px 可完整显示;2) 通知和审批卡片标题仍可省略但不挤压状态;3) 页面 scrollWidth 不超过视口;4) Playwright 移动矩阵通过。","流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",npm|playwright,DONE,DONE,DONE,client/src/styles/mobile-touch.css|client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileNotificationsPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/tests/e2e,"[""R7E-16""]",回滚路径:恢复 mobile-status-slot 旧样式和相关测试断言;不改变业务数据或路由。
|
||||
R7E-16B,Android PDF 上传兜底校验,"补强移动发票上传的前端 PDF 过滤,记录 Android 企业微信系统选择器可能仍显示拍照和选择文件但 OA 不接收非 PDF 发票。","1) PDF 上传入口 accept 为 .pdf,application/pdf 且无 capture;2) 选择非 PDF 时前端拒绝并提示;3) 不把图片 OCR 误报为支持;4) 既有 PDF 上传测试通过。","流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/r111-invoice-reimbursement-guard.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",npm|playwright,DONE,DONE,DONE,client/src/lib/pdfFiles.ts|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/invoice-pool/InvoiceUploadCard.tsx|client/tests/e2e,"[""R7E-16""]",Android WebView 文件选择器 UI 受系统和企业微信控制;本轮只保证 OA 端 PDF-only 校验和可解释提示 不新增图片 OCR。
|
||||
R7E-17,阻断问题修复分流,"根据每日问题清单把阻断问题分流到当前 R7E 修复或后续连续治理流。","1) 阻断问题有 owner;2) 能在 R7E 内修的有方案和验证;3) 范围外问题进入 UX8/ORG8/WF8/BIZ8/QAOPS8;4) 不顺手扩大。","流程级验证:人工核对问题清单和分流结果;必要时创建后续连续 CSV。",manual,TODO,TODO,TODO,issues/*.csv|docs/trialrun/人工试运行报告.md,"[""R7E-16""]",涉及代码修复时必须先方案批准。
|
||||
R7E-18,验收结论,"汇总 R7E 真实试运行结果,给出通过、暂停、回滚或继续观察结论。","1) 主流程结论完整;2) 阻断问题为 0 或明确暂停/回滚;3) 用户验收结论记录;4) 未完成项进入后续治理流。","流程级验证:人工验收;报告中列出流程结论、问题清单和后续动作。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-17""]",不能仅凭自动化通过宣布 R7E 完成。
|
||||
R7E-19,P2 排序调整,"根据 R7E 真实反馈调整 P2 候选优先级和后续连续治理流顺序。","1) P2 排序基于真实反馈;2) ORG8/WF8/BIZ8/QAOPS8 入口明确;3) 冻结项仍不进入;4) 如需变更 PRD 则另走事实源更新。","流程级验证:人工核对 PRD P2T 表、问题清单和后续 Issue 计划。",manual|rg,TODO,TODO,TODO,docs/海音达OA-MVP需求规格说明书.md|issues/*.csv,"[""R7E-18""]",P2 功能开发必须另建或延续对应连续合同。
|
||||
|
||||
|
@@ -10,7 +10,8 @@ WF8-7,邮件通道决策记录,"决定邮件通道是否进入当前验收,若
|
||||
WF8-8,三渠道通知验收设计,"设计站内、企业微信个人应用消息和邮件或冻结替代通道的三渠道验收口径。","1) 三渠道或二渠道加冻结说明清楚;2) deeplink 回 OA;3) 缺配置时 SKIP 不等于 PASS;4) 真机和公网边界明确。","流程级验证:人工审查通知矩阵;必要时执行 live readiness 和企业微信真机验证。",manual|bash|playwright,DONE,DONE,DONE,docs/试运行与自动化验证手册.md|docs/需求-企业微信基础设施规约.md,"[""WF8-6"",""WF8-7""]",Runbook 已新增 WF8 通知渠道验收矩阵:站内/企业微信个人应用/邮件冻结/失败追溯四类口径;明确本地自动化/公网匿名/公网带凭据/企业微信真机证据边界;rg Test_Method 已通过;本任务仅设计验收口径 未执行真机验收。
|
||||
WF8-9,催办和超时提醒回归,"回归既有催办和超时提醒,避免转审或通知增强引入倒退。","1) 催办不改变审批状态;2) 超时提醒不重复刷屏;3) 通知对象正确;4) 历史记录可追溯。","流程级验证:创建待审批任务 -> 触发催办或超时提醒 -> 检查通知和 workflow history。",node|bash|manual,DONE,DONE,DONE,server/src|server/scripts|docs/需求-统一审批中心规约.md,"[""WF8-6""]",AP6 后提醒能力是当前事实;已跑 E2E AP6 审批催办与待办汇总 1 passed 覆盖申请人催办/经理和 admin 禁止/重复 24h 限制/history/notification/audit/summary;已跑 workflow.travel、workflowOverdueReminderJob、notificationTypes 单测 7 passed 覆盖超时提醒 job 和通知类型;node --check workflow.js/workflowOverdueReminderJob.js 通过。
|
||||
WF8-10,审批历史一致性,"确保转审、催办、通过、驳回和通知事件在审批历史中一致展示。","1) 历史顺序清楚;2) 操作人和目标人可解释;3) 不泄露敏感字段;4) PC 和移动展示一致。","流程级验证:提交审批 -> 转审 -> 催办 -> 审批处理 -> PC 和移动查看历史。",playwright|node|manual,DONE,DONE,DONE,server/src|client/src,"[""WF8-3"",""WF8-4"",""WF8-5"",""WF8-9""]",完成:MobileApprovalsPage 渲染 selectedTask.history 的移动处理历史卡片并复用动作中文映射;仅展示动作 操作人 意见 时间和空态 不渲染 payload;不改后端 数据结构 权限或状态机;验证 npm run typecheck 通过;npm run build 通过;npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts --workers=1 --reporter=list 在启动本地 3000 服务后 5 passed。
|
||||
WF8-11,通知 deeplink 回 OA 验证,"验证企业微信个人应用消息或站内通知 deeplink 能回到 OA 目标页面。","1) deeplink 指向具体业务目标;2) 不只返回通知中心;3) 未登录或未绑定有可解释提示;4) 移动布局正常。","流程级验证:触发通知 -> 点击 deeplink -> 企业微信或移动浏览器打开 OA 目标详情 -> 检查权限和页面。",playwright|manual,TODO,TODO,TODO,client/src|server/src|docs/trialrun/真机公网测试清单.md,"[""WF8-8"",""WF8-10""]",真机验收不能被自动化替代。
|
||||
WF8-11,通知 deeplink 回 OA 验证,"验证企业微信个人应用消息或站内通知 deeplink 能回到 OA 目标页面。","1) deeplink 指向具体业务目标;2) 不只返回通知中心;3) 未登录或未绑定有可解释提示;4) 移动布局正常。","流程级验证:触发通知 -> 点击 deeplink -> 企业微信或移动浏览器打开 OA 目标详情 -> 检查权限和页面。",playwright|manual,TODO,TODO,TODO,client/src|server/src|docs/trialrun/真机公网测试清单.md,"[""WF8-8"",""WF8-10"",""WF8-11A""]",真机验收不能被自动化替代;2026-05-14 用户真机反馈为企业微信免登录通过 但个人应用消息点击均提示 requestid tokenexpired 或深链已过期 本行等待修复后复测。
|
||||
WF8-11A,企业微信消息过期深链修复,"修复企业微信个人应用消息中的 OA deeplink 过期后无法回到目标单据或审批页的问题,保留签名校验和内部路径白名单。","1) 签名有效且 targetPath 为内部相对路径时 过期旧 token 也能回 OA;2) 企业微信 WebView 优先进入 wecom login 并带 returnTo;3) 普通浏览器仍走目标路径和现有登录 redirect;4) 无效签名 绝对 URL 和 // 路径仍拒绝。","流程级验证:cd server && node --check src/routes/auth.js src/services/notifications/detailUrl.js;cd server && npm test -- tests/unit/services/imEnqueue.test.js tests/unit/services/notificationDispatcher.test.js tests/unit/routes/auth.login.test.js tests/unit/routes/wecomAuth.test.js。",node|npm,DONE,DONE,DONE,server/src/routes/auth.js|server/src/services/notifications/detailUrl.js|server/tests/unit,"[""WF8-10""]",回滚路径:恢复 /api/auth/deeplink 的 exp 硬过期检查和旧 detailUrl payload;不会修改 workflow 状态机或通知通道配置。
|
||||
WF8-12,workflow reviewer 审查,"对 WF8 涉及的 workflow 状态、权限和通知副作用做独立 review。","1) 非法状态跳转无阻断;2) 权限泄漏无阻断;3) 重复审批和并发风险可控;4) 漏测项有归属。","流程级验证:reviewer 人工审查 diff、Issue CSV 和测试证据。",manual,TODO,TODO,TODO,issues/2026-05-13_18-23-52-wf8-approval-notification.csv|server/src|client/src,"[""WF8-10"",""WF8-11""]",实现后立即 review。
|
||||
WF8-13,WF8 收口准备,"整理 WF8 验证摘要、通知矩阵、未完成项和后续业务影响。","1) DONE 行均有证据;2) 通知渠道结论清楚;3) 未完成项不伪装完成;4) 文档入口同步。","流程级验证:人工核对 WF8 CSV 状态、验证摘要、通知矩阵和 git diff。",manual|git,TODO,TODO,TODO,issues/2026-05-13_18-23-52-wf8-approval-notification.csv|docs/文档索引.md,"[""WF8-12""]",对应原规划 WF8-13。
|
||||
WF8-CLOSE,WF8 阶段 CLOSE,"阶段性关闭 WF8,完成状态回填、验证摘要和中文提交。","1) CSV 校验通过;2) git diff --check 通过;3) 所有 DONE 有命令或人工证据;4) 中文提交引用 WF8;5) 后续风险有归属。","流程级验证:validate_issues_csv.py 本 CSV;git diff --check;人工核对最终摘要。",python3|git|manual,TODO,TODO,TODO,issues/2026-05-13_18-23-52-wf8-approval-notification.csv,"[""WF8-13""]",每个连续 CSV 保留 CLOSE 行。
|
||||
|
||||
|
@@ -12,6 +12,7 @@ BIZ8-9,付款和导出增强,"增强或验证财务付款标记和导出路径
|
||||
BIZ8-10,费用提示轻量规则,"设计轻量费用提示规则,作为填报辅助而非重预算控制。","1) 提示不阻断合法报销;2) 规则来源清楚;3) 不承诺预算控制;4) 用户可理解。","流程级验证:填写不同金额或类型报销 -> 检查费用提示 -> 提交和审批不被误阻断。",manual|playwright,DONE,DONE,DONE,docs/报销发票附件规则.md|client/src|server/src,"[""BIZ8-1""]",已在 docs/需求-报销发票OCR规约.md 增加 BIZ8 费用提示轻量规则,明确材料提示、金额提示、费用标准 warn、费用标准 block 和预算提示边界;npm test -- tests/unit/services/expenseStandards.check.test.js tests/unit/routes/reimbursements.expense-standards.test.js 通过 2 files 9 tests,覆盖 warn 不阻断、block 阻断和 manual_review 优先级;未改业务代码。
|
||||
BIZ8-11,项目关联增强,"增强报销、采购或合同与项目轻管理的关联,保持轻量不扩展成重型项目管理。","1) 项目选择和展示清楚;2) 无项目时可解释;3) 项目权限不泄露;4) 不扩展复杂 WBS。","流程级验证:创建或选择项目 -> 保存采购草稿 -> 项目负责人查看项目采购摘要 -> 采购详情权限直连复核;自动化覆盖 projectId 列表过滤 归档项目新关联拦截 已有关联不重复阻断 项目成员不能越过采购详情权限。",node|npm|git|manual,DONE,DONE,DONE,server/prisma|server/src|server/tests|client/src,"[""BIZ8-1""]",完成:采购新增 nullable project_id 外键 ON DELETE SET NULL 和索引;采购列表详情草稿更新补项目字段;新关联校验项目可读且未归档;已有项目无关编辑不重复阻断;项目详情新增轻量采购摘要;前端采购表单列表详情和项目详情补展示;验证 npm run prisma:validate 通过;node --check purchaseRequests projects 通过;npm test purchase.project-link projects purchase.submit-budget-precheck 3 files 12 tests passed;client typecheck 和 build 通过;git diff --check 通过;reviewer 已复核并修复阻断问题;回滚:代码回滚可保留 nullable 列,物理回滚需先 drop constraint purchase_requests_project_id_fkey 再 drop index idx_purchase_requests_project 再 drop column purchase_requests.project_id 且关联数据会丢失;继续冻结复杂 WBS 重预算 ERP 实时集成。
|
||||
BIZ8-12,移动端业务入口评估,"评估报销、OCR、财务和项目轻量能力在移动端和企业微信入口的可用性。","1) 移动入口不重复;2) 企业微信入口回 OA;3) 财务敏感能力不误暴露;4) 新增入口进入移动矩阵。","流程级验证:员工和财务角色在移动端进入业务入口 -> 检查报销/OCR/项目状态和权限。",playwright|manual,DOING,TODO,TODO,client/src|docs/产品设计规则.md|docs/trialrun/真机公网测试清单.md,"[""BIZ8-8"",""BIZ8-11""]",自动化评估已启动但不能关闭:只读审查确认 BIZ8-12 必须保留跨视口自动化加代表性真机确认;代码路径显示移动首页按权限裁剪报销 OCR 采购 资产入口 财务摘要仅财务角色可见 PC 财务路由在移动上下文回 /mobile;MOBILE_VISUAL_OUTPUT_DIR=biz8-12-mobile-entry npx playwright test r6-mobile-unified-design-rollout r124-mobile-entry-matrix r110-mobile-pc-routing wecom-entry --workers=1 --reporter=list 在临时 Vite 3000 下执行,r124 1 passed、r6 移动统一设计 7 passed、wecom-entry 企业微信未绑定场景 1 passed,生成 output/playwright/biz8-12-mobile-entry 77 张截图;r110 和 wecom 普通浏览器登录依赖本地后端/API 环境失败,不能作为页面回归失败;当前阻断为缺少 BIZ8-12 代表性真机和财务角色手机浏览器/PC 公网人工验收,SMOKE301/SMOKE302 无企业微信不可作为企微成员;保持 DOING,不标 DONE。
|
||||
BIZ8-13,财务和项目权限回归,"回归财务台账、付款导出、项目关联和报销详情的权限边界。","1) 普通员工只能看自己的数据;2) 财务只看授权财务数据;3) 管理员不自动获得财务处理;4) 项目关联不越权。","流程级验证:不同角色直连财务、项目、报销详情和导出 API -> 检查允许和拒绝结果。",bash|node|manual,TODO,TODO,TODO,scripts/qa|server/src|client/src,"[""BIZ8-9"",""BIZ8-11"",""BIZ8-12""]",高风险权限变更必须 reviewer。
|
||||
BIZ8-12A,财务移动入口和付款未启用空态,"在移动首页和我的页补财务角色入口,并在 payment_batch_v1 关闭时展示付款批次未启用的明确状态,不启用付款能力。","1) 财务角色可在 /mobile 和 /mobile/me 找到财务入口;2) 非财务角色不显示财务入口;3) payment_batch_v1 关闭时不显示误导性待付款 0 或台账导出可用;4) 不改变权限和付款状态机。","流程级验证:cd server && node --check src/routes/portal.js;cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",node|npm|playwright,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/src/api/portal.ts|server/src/routes/portal.js|client/tests/e2e,"[""BIZ8-12""]",本轮只开入口和解释性空态 不开启 payment_batch_v1 不修改 AP/payment-batches API 权限或状态机;公网付款闭环仍待功能门禁决策。
|
||||
BIZ8-13,财务和项目权限回归,"回归财务台账、付款导出、项目关联和报销详情的权限边界。","1) 普通员工只能看自己的数据;2) 财务只看授权财务数据;3) 管理员不自动获得财务处理;4) 项目关联不越权。","流程级验证:不同角色直连财务、项目、报销详情和导出 API -> 检查允许和拒绝结果。",bash|node|manual,TODO,TODO,TODO,scripts/qa|server/src|client/src,"[""BIZ8-9"",""BIZ8-11"",""BIZ8-12"",""BIZ8-12A""]",高风险权限变更必须 reviewer。
|
||||
BIZ8-14,BIZ8 收口准备,"整理 BIZ8 验证摘要、OCR 边界、财务和项目权限结论、未完成项。","1) DONE 行均有证据;2) OCR 真实与 mock 结论分离;3) 权限回归通过或有阻断;4) 未完成项不伪装完成。","流程级验证:人工核对 BIZ8 CSV 状态、验证摘要、OCR 证据和权限回归结果。",manual|git,TODO,TODO,TODO,issues/2026-05-13_18-23-53-biz8-finance-ocr-project.csv|docs/文档索引.md,"[""BIZ8-13""]",对应原规划 BIZ8-14。
|
||||
BIZ8-CLOSE,BIZ8 阶段 CLOSE,"阶段性关闭 BIZ8,完成状态回填、验证摘要和中文提交。","1) CSV 校验通过;2) git diff --check 通过;3) 所有 DONE 有命令或人工证据;4) 中文提交引用 BIZ8;5) 后续风险有归属。","流程级验证:validate_issues_csv.py 本 CSV;git diff --check;人工核对最终摘要。",python3|git|manual,TODO,TODO,TODO,issues/2026-05-13_18-23-53-biz8-finance-ocr-project.csv,"[""BIZ8-14""]",每个连续 CSV 保留 CLOSE 行。
|
||||
|
||||
|
@@ -9,6 +9,8 @@ GOV8 后默认采用“少量长期连续合同 + 多行任务 + 阶段收口”
|
||||
- 同类型、同目标、同风险域的问题进入同一个连续 CSV。
|
||||
- 一个连续 CSV 最多承载一个治理流,不混入无关业务域。
|
||||
- 只有跨业务域、跨风险等级、跨里程碑,或当前治理流阶段性收口后,才新建下一版 CSV。
|
||||
- 新反馈进入执行前必须先查当前活跃治理流;同类型反馈优先追加新行或更新 Notes,不因单点修复、试运行反馈、真机反馈或小范围返工新建一次性 CSV。
|
||||
- 若确需新建 CSV,必须在方案中说明为什么现有活跃流不能承载,例如没有对应治理流、当前流已 CLOSE、涉及跨域高风险变更或用户明确要求新合同。
|
||||
- 旧 Issue CSV 不删除、不批量回写、不移动到归档目录;它们是历史审计证据。
|
||||
- 当前可执行合同以 `docs/文档索引.md` 和 `docs/文档事实源状态矩阵.md` 标注的“当前活跃治理流”为准。
|
||||
|
||||
|
||||
+15
-10
@@ -182,6 +182,17 @@ function sanitizeReturnTo(value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isWecomWebViewRequest(request) {
|
||||
const userAgent = String(request.headers["user-agent"] ?? "").toLowerCase();
|
||||
return userAgent.includes("wxwork");
|
||||
}
|
||||
|
||||
function buildWecomLoginRedirect(origin, targetPath) {
|
||||
const params = new URLSearchParams({ returnTo: targetPath });
|
||||
const path = `/api/auth/wecom/login?${params.toString()}`;
|
||||
return origin ? `${origin}${path}` : path;
|
||||
}
|
||||
|
||||
function parseAccessTokenTtlSeconds(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
@@ -737,8 +748,7 @@ async function authRoutes(fastify) {
|
||||
|
||||
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
|
||||
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
|
||||
const exp = Number(payload.exp ?? 0);
|
||||
if (!targetPath || !targetPath.startsWith("/")) {
|
||||
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
@@ -746,16 +756,11 @@ async function authRoutes(fastify) {
|
||||
details: { targetPath: "invalid_target_path" },
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(exp) || Date.now() / 1000 > exp) {
|
||||
throw new AppError({
|
||||
code: "AUTH_001",
|
||||
message: "深链已过期",
|
||||
statusCode: 401,
|
||||
details: { token: "expired" },
|
||||
});
|
||||
}
|
||||
|
||||
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
|
||||
if (isWecomWebViewRequest(request)) {
|
||||
return reply.redirect(buildWecomLoginRedirect(origin, targetPath));
|
||||
}
|
||||
return reply.redirect(origin ? `${origin}${targetPath}` : targetPath);
|
||||
});
|
||||
|
||||
|
||||
@@ -607,12 +607,12 @@ async function portalRoutes(fastify) {
|
||||
select: { project_id: true },
|
||||
}).then((items) => items.length)
|
||||
: Promise.resolve(0),
|
||||
roleCapabilities.canUseFinance
|
||||
roleCapabilities.canUseFinance && featureEnabled.paymentBatch
|
||||
? fastify.prisma.paymentBatch.count({
|
||||
where: { status: { in: ["draft", "reviewed", "failed"] } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
roleCapabilities.canUseFinance
|
||||
roleCapabilities.canUseFinance && featureEnabled.paymentBatch
|
||||
? fastify.prisma.paymentBatchItem.count({ where: { status: "failed" } })
|
||||
: Promise.resolve(0),
|
||||
buildRecentActivities(fastify.prisma),
|
||||
@@ -814,6 +814,7 @@ async function portalRoutes(fastify) {
|
||||
pendingBatchCount: pendingFinanceBatchCount,
|
||||
failedItemCount: failedFinanceItemCount,
|
||||
riskCount,
|
||||
paymentBatchEnabled: featureEnabled.paymentBatch,
|
||||
href: "/ap-operations",
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -24,7 +24,6 @@ function buildDetailUrl(config, targetPath) {
|
||||
const token = signPayload(
|
||||
{
|
||||
targetPath: normalizedPath,
|
||||
exp: Math.floor(Date.now() / 1000) + 15 * 60,
|
||||
},
|
||||
config.jwtSecret,
|
||||
);
|
||||
|
||||
@@ -99,6 +99,90 @@ async function buildApp(passwordHash) {
|
||||
}
|
||||
|
||||
describe("auth login route", () => {
|
||||
it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app } = await buildApp(passwordHash);
|
||||
const token = signPayload({
|
||||
targetPath: "/mobile/approvals?taskId=task-1",
|
||||
exp: Math.floor(Date.now() / 1000) - 60,
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe("http://127.0.0.1:3000/mobile/approvals?taskId=task-1");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("routes signed deeplink through wecom login inside Enterprise WeChat", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app } = await buildApp(passwordHash);
|
||||
const token = signPayload({
|
||||
targetPath: "/mobile/approvals?taskId=task-1",
|
||||
exp: Math.floor(Date.now() / 1000) - 60,
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
|
||||
headers: {
|
||||
"user-agent": "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/8.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe(
|
||||
"http://127.0.0.1:3000/api/auth/wecom/login?returnTo=%2Fmobile%2Fapprovals%3FtaskId%3Dtask-1",
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects deeplink targets outside internal OA paths", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app } = await buildApp(passwordHash);
|
||||
const token = signPayload({ targetPath: "//evil.example.com/mobile" }, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects tampered deeplink signatures and absolute URL targets", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app } = await buildApp(passwordHash);
|
||||
const validToken = signPayload({ targetPath: "/mobile" }, JWT_SECRET);
|
||||
|
||||
const tamperedResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/deeplink?token=${encodeURIComponent(`${validToken}x`)}`,
|
||||
});
|
||||
|
||||
expect(tamperedResponse.statusCode).toBe(401);
|
||||
expect(tamperedResponse.json().error.details).toEqual({ token: "invalid_signature" });
|
||||
|
||||
const absoluteUrlToken = signPayload({ targetPath: "https://evil.example.com/mobile" }, JWT_SECRET);
|
||||
const absoluteUrlResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/deeplink?token=${encodeURIComponent(absoluteUrlToken)}`,
|
||||
});
|
||||
|
||||
expect(absoluteUrlResponse.statusCode).toBe(400);
|
||||
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("auto-binds pending wecom identity after password login", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||
|
||||
@@ -4,12 +4,18 @@ const {
|
||||
enqueueImNotifications,
|
||||
resolveRecipientRoleCodes,
|
||||
} = require("../../../src/services/notifications/imEnqueue");
|
||||
const { verifyToken } = require("../../../src/utils/signedUrl");
|
||||
|
||||
describe("IM enqueue", () => {
|
||||
it("builds detail url from APP_ORIGIN", () => {
|
||||
expect(buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1")).toContain(
|
||||
const detailUrl = buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1");
|
||||
expect(detailUrl).toContain(
|
||||
"https://app.example.com/api/auth/deeplink?token=",
|
||||
);
|
||||
const token = new URL(detailUrl).searchParams.get("token");
|
||||
const verified = verifyToken(token, "test-secret");
|
||||
expect(verified.ok).toBe(true);
|
||||
expect(verified.payload).toEqual({ targetPath: "/workflow/tasks/1" });
|
||||
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户