R7E-16D 审批动作二次确认
Refs: R7E-16D BIZ8-12B QAOPS8-17
这个提交包含在:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,10 +13,13 @@ type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "destructive";
|
||||
testId?: string;
|
||||
confirmTestId?: string;
|
||||
cancelTestId?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -28,6 +31,9 @@ export function ConfirmDialog({
|
||||
confirmLabel = "确认",
|
||||
cancelLabel = "取消",
|
||||
variant = "default",
|
||||
testId,
|
||||
confirmTestId,
|
||||
cancelTestId,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -44,7 +50,7 @@ export function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogContent className="max-w-sm" data-testid={testId}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
@@ -54,6 +60,7 @@ export function ConfirmDialog({
|
||||
variant="secondary"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid={cancelTestId}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
@@ -62,6 +69,7 @@ export function ConfirmDialog({
|
||||
variant={variant === "destructive" ? "destructive" : "default"}
|
||||
disabled={pending}
|
||||
onClick={handleConfirm}
|
||||
data-testid={confirmTestId}
|
||||
type="button"
|
||||
>
|
||||
{pending ? "处理中..." : confirmLabel}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export type WorkflowConfirmAction = "approve" | "reject" | "return";
|
||||
|
||||
export type WorkflowConfirmCopy = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
variant: "default" | "destructive";
|
||||
};
|
||||
|
||||
const CONFIRM_COPY: Record<WorkflowConfirmAction, WorkflowConfirmCopy> = {
|
||||
approve: {
|
||||
title: "确认通过审批?",
|
||||
description: "确认后将进入下一审批节点;如果没有下一节点,单据将审批通过。系统会写入审批历史。",
|
||||
confirmLabel: "确认通过",
|
||||
variant: "default",
|
||||
},
|
||||
reject: {
|
||||
title: "确认驳回审批?",
|
||||
description: "驳回后当前流程将终止或进入驳回态。申请人通常需要复制或重新发起;本次驳回意见会写入审批历史。",
|
||||
confirmLabel: "确认驳回",
|
||||
variant: "destructive",
|
||||
},
|
||||
return: {
|
||||
title: "确认退回审批?",
|
||||
description: "退回后单据会回到申请人修改态,保留原单号和审批历史;申请人修改后可重新提交。",
|
||||
confirmLabel: "确认退回",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export function getWorkflowConfirmCopy(action: WorkflowConfirmAction, options: { offlineDraft?: boolean } = {}) {
|
||||
const copy = CONFIRM_COPY[action];
|
||||
if (!options.offlineDraft) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
return {
|
||||
...copy,
|
||||
title: action === "approve" ? "确认保存通过草稿?" : "确认保存驳回草稿?",
|
||||
description: `${copy.description} 当前离线时只会保存为待同步草稿,联网同步成功后才会正式提交。`,
|
||||
confirmLabel: action === "approve" ? "保存通过草稿" : "保存驳回草稿",
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
@@ -33,6 +34,8 @@ import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { cn } from "../lib/utils";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
import {
|
||||
cacheWorkflowTaskDetail,
|
||||
enqueueWorkflowActionDraft,
|
||||
@@ -390,12 +393,16 @@ export function MobileApprovalsPage() {
|
||||
const [transferToApproverId, setTransferToApproverId] = useState("");
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||
const confirmCopy = confirmAction
|
||||
? getWorkflowConfirmCopy(confirmAction, { offlineDraft: !canUseRemote && confirmAction !== "return" })
|
||||
: null;
|
||||
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
const employeeProgressStatus = progressStatusQuery(progressTab);
|
||||
@@ -631,6 +638,26 @@ export function MobileApprovalsPage() {
|
||||
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }),
|
||||
});
|
||||
|
||||
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (!canUseRemote && action === "return") {
|
||||
setError("当前离线不支持退回,请联网后再处理。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "reject" && !comment.trim()) {
|
||||
setError("驳回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "return" && !comment.trim()) {
|
||||
setError("退回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
async function refreshDraftQueueCache() {
|
||||
if (!ownerEmployeeId) {
|
||||
return [];
|
||||
@@ -962,9 +989,18 @@ export function MobileApprovalsPage() {
|
||||
const allVisibleSelected =
|
||||
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
||||
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
||||
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<>
|
||||
<PageScaffold
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
showMobileApprovalActions
|
||||
? "mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{isPushDetail ? (
|
||||
<MobileDetailHeader
|
||||
title="审批详情"
|
||||
@@ -1401,48 +1437,41 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
) : null}
|
||||
|
||||
<div className={canUseRemote ? "mobile-sticky-action-grid mobile-approval-action-grid" : "mobile-sticky-action-grid"}>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => {
|
||||
if (canUseRemote) {
|
||||
actionMutation.mutate("approve");
|
||||
return;
|
||||
}
|
||||
void queueOfflineDraft("approve");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{canUseRemote ? "通过" : "保存通过草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => {
|
||||
if (canUseRemote) {
|
||||
actionMutation.mutate("reject");
|
||||
return;
|
||||
}
|
||||
void queueOfflineDraft("reject");
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{canUseRemote ? "驳回" : "保存驳回草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => actionMutation.mutate("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<div
|
||||
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
|
||||
data-testid="mobile-approvals-sticky-actions"
|
||||
>
|
||||
<div className="mobile-sticky-action-grid mobile-approval-action-grid">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => requestMobileWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => requestMobileWorkflowAction("reject")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => requestMobileWorkflowAction("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
@@ -1587,5 +1616,33 @@ export function MobileApprovalsPage() {
|
||||
</section>
|
||||
) : null}
|
||||
</PageScaffold>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
if (canUseRemote) {
|
||||
await actionMutation.mutateAsync(confirmAction);
|
||||
return;
|
||||
}
|
||||
if (confirmAction !== "return") {
|
||||
await queueOfflineDraft(confirmAction);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
@@ -27,6 +28,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
|
||||
function entityTitle(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
@@ -114,6 +116,7 @@ export function WorkflowTaskDetailPage() {
|
||||
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||
const [selectedApprover, setSelectedApprover] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(taskId),
|
||||
@@ -184,6 +187,23 @@ export function WorkflowTaskDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
|
||||
|
||||
function requestWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (action === "reject" && rejectComment.trim().length < 1) {
|
||||
setActionError("驳回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "return" && returnComment.trim().length < 1) {
|
||||
setActionError("退回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError("");
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="card-std p-6">
|
||||
@@ -229,6 +249,7 @@ export function WorkflowTaskDetailPage() {
|
||||
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`任务详情 ${detail.task.stepName}`}
|
||||
@@ -365,7 +386,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||
onClick={() => mutation.mutate("approve")}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
@@ -384,7 +405,7 @@ export function WorkflowTaskDetailPage() {
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
onClick={() => mutation.mutate("reject")}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
提交驳回
|
||||
@@ -403,7 +424,7 @@ export function WorkflowTaskDetailPage() {
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
onClick={() => mutation.mutate("return")}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
提交退回
|
||||
@@ -482,5 +503,27 @@ export function WorkflowTaskDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
await mutation.mutateAsync(confirmAction);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type WorkflowTaskSummary,
|
||||
} from "../api/workflow";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -26,6 +28,7 @@ import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const WORKFLOW_STATUS_OPTIONS = [
|
||||
@@ -130,6 +133,7 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||
@@ -194,8 +198,10 @@ export function WorkflowTasksPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
@@ -318,7 +324,7 @@ export function WorkflowTasksPage() {
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => approveMutation.mutate(task.id)}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
@@ -381,7 +387,7 @@ export function WorkflowTasksPage() {
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => approveMutation.mutate(task.id)}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
@@ -418,5 +424,27 @@ export function WorkflowTasksPage() {
|
||||
testIdPrefix="workflow-tasks"
|
||||
/>
|
||||
</ListWorkspace>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmApproveTaskId(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmApproveTaskId) {
|
||||
return;
|
||||
}
|
||||
await approveMutation.mutateAsync(confirmApproveTaskId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ export class WorkflowTaskDetailPageObject {
|
||||
return this.page.getByTestId(TEST_IDS.workflow.actionError);
|
||||
}
|
||||
|
||||
get actionConfirmDialog() {
|
||||
return this.page.getByTestId("workflow-action-confirm-dialog");
|
||||
}
|
||||
|
||||
get actionConfirmSubmit() {
|
||||
return this.page.getByTestId("workflow-action-confirm-submit");
|
||||
}
|
||||
|
||||
get transferApproverSelect() {
|
||||
return this.page.getByTestId(TEST_IDS.workflow.transferApprover);
|
||||
}
|
||||
@@ -52,15 +60,21 @@ export class WorkflowTaskDetailPageObject {
|
||||
|
||||
async approve() {
|
||||
await this.approveBtn.click();
|
||||
await expect(this.actionConfirmDialog).toContainText("确认后将进入下一审批节点");
|
||||
await this.actionConfirmSubmit.click();
|
||||
}
|
||||
|
||||
async reject(comment: string) {
|
||||
await this.rejectCommentInput.fill(comment);
|
||||
await this.rejectBtn.click();
|
||||
await expect(this.actionConfirmDialog).toContainText("当前流程将终止");
|
||||
await this.actionConfirmSubmit.click();
|
||||
}
|
||||
|
||||
async returnTask(comment: string) {
|
||||
await this.returnCommentInput.fill(comment);
|
||||
await this.returnBtn.click();
|
||||
await expect(this.actionConfirmDialog).toContainText("回到申请人修改态");
|
||||
await this.actionConfirmSubmit.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
await expect(page.getByText("当前离线")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByRole("main").getByText("当前离线,通过草稿已加入待同步队列。")).toBeVisible();
|
||||
|
||||
await page.context().setOffline(false);
|
||||
@@ -119,6 +121,8 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
|
||||
await page.getByTestId("mobile-approvals-reject").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByRole("main").getByText("当前离线,驳回草稿已加入待同步队列。")).toBeVisible();
|
||||
|
||||
await approveWorkflowTaskViaApi(managerApi.api, task.id);
|
||||
|
||||
@@ -92,6 +92,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("对象类型:采购申请");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("申请人:Employee");
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(已处理|已完成|completed)/);
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
@@ -481,6 +481,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
|
||||
await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证");
|
||||
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail" });
|
||||
await manager390.page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(manager390.page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await manager390.page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 });
|
||||
await manager390.context.close();
|
||||
|
||||
|
||||
@@ -416,7 +416,19 @@ test.describe("R89 移动底部导航与核心页面", () => {
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await attachScreenshot(page, testInfo, "r97-mobile-approvals.png");
|
||||
await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
|
||||
await expect(page).toHaveURL(/\/mobile\/approvals/);
|
||||
|
||||
await page.getByTestId("mobile-nav-reimbursements").click();
|
||||
await expect(page).toHaveURL(/\/mobile\/reimbursements/);
|
||||
|
||||
@@ -28,6 +28,8 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
||||
await expect(taskRow).toBeVisible({ timeout: 30_000 });
|
||||
await taskRow.getByTestId("workflow-task-detail-link").click();
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("已完成");
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { login, MANAGER } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return { requestId: "mock-workflow-confirm", data };
|
||||
}
|
||||
|
||||
async function fulfillJson<T>(route: Route, data: T) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
const mockMe = {
|
||||
id: "employee-confirm-manager",
|
||||
employeeNo: "M-confirm",
|
||||
name: "Manager",
|
||||
email: "[email protected]",
|
||||
phone: null,
|
||||
roleCodes: ["manager"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 70,
|
||||
},
|
||||
};
|
||||
|
||||
const pendingTask = {
|
||||
id: "task-confirm-1",
|
||||
instanceId: "instance-confirm-1",
|
||||
stepId: "manager-review",
|
||||
stepName: "主管审批",
|
||||
assigneeId: mockMe.id,
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
createdAt: "2026-05-15T09:00:00.000Z",
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
entityType: "leave",
|
||||
entityId: "leave-confirm-1",
|
||||
currentStatus: "reviewing",
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "QJ-CONFIRM-001",
|
||||
title: "R7E 审批确认测试",
|
||||
applicantName: "Employee",
|
||||
amount: null,
|
||||
duration: "1 天",
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
};
|
||||
|
||||
const pendingTaskDetail = {
|
||||
task: {
|
||||
id: pendingTask.id,
|
||||
instanceId: pendingTask.instanceId,
|
||||
stepId: pendingTask.stepId,
|
||||
stepName: pendingTask.stepName,
|
||||
assigneeId: pendingTask.assigneeId,
|
||||
assigneeRole: pendingTask.assigneeRole,
|
||||
status: pendingTask.status,
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: pendingTask.createdAt,
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: pendingTask.instanceId,
|
||||
entityType: "leave",
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "manager-review",
|
||||
contextData: { standardViolations: [] },
|
||||
startedAt: "2026-05-15T09:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: null,
|
||||
leaveRequest: {
|
||||
id: "leave-confirm-1",
|
||||
status: "reviewing",
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-01",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
requestedDays: 1,
|
||||
reason: "R7E 审批动作确认",
|
||||
attachment: null,
|
||||
leaveType: { id: "annual", code: "annual", name: "年假" },
|
||||
owner: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "Employee" },
|
||||
},
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
allowedActions: pendingTask.allowedActions,
|
||||
history: [],
|
||||
};
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
async function mockWorkflowConfirmApis(page: Page) {
|
||||
const actionCalls = {
|
||||
approve: 0,
|
||||
reject: 0,
|
||||
return: 0,
|
||||
};
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({
|
||||
accessToken: "mock-workflow-confirm-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
|
||||
body: JSON.stringify(envelope({})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, mockMe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-confirm",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
|
||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/notifications") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
|
||||
total: 0,
|
||||
unreadOnly: url.searchParams.get("unreadOnly") === "1",
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
hero: { title: "工作台", subtitle: "审批动作确认测试" },
|
||||
metrics: [],
|
||||
todos: [],
|
||||
notifications: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks/summary") {
|
||||
await fulfillJson(route, {
|
||||
scope: "mine",
|
||||
pendingTotal: actionCalls.approve > 0 ? 0 : 1,
|
||||
overdueTotal: 0,
|
||||
earliestPendingAt: pendingTask.createdAt,
|
||||
averagePendingAgeHours: 1,
|
||||
byEntityType: [],
|
||||
byStep: [],
|
||||
byAssigneeRole: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks") {
|
||||
const status = url.searchParams.get("status") ?? "pending";
|
||||
const items = status === "pending" && actionCalls.approve === 0 ? [pendingTask] : [];
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: items.length,
|
||||
items,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = path.match(/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return)$/);
|
||||
if (actionMatch) {
|
||||
const action = actionMatch[1] as keyof typeof actionCalls;
|
||||
actionCalls[action] += 1;
|
||||
await fulfillJson(route, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) {
|
||||
await fulfillJson(route, pendingTaskDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/approvers") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
return actionCalls;
|
||||
}
|
||||
|
||||
test.describe("R7E 审批动作二次确认", () => {
|
||||
test("PC 列表快捷通过必须二次确认后才提交", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowConfirmApis(page);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto("/workflow/tasks");
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
|
||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: "QJ-CONFIRM-001" });
|
||||
await expect(taskRow).toHaveCount(1);
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.approve).toBe(1);
|
||||
});
|
||||
|
||||
test("PC 审批详情通过、驳回、退回都先解释动作后果", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowConfirmApis(page);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
|
||||
await page.getByTestId("workflow-task-reject").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.reject).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
|
||||
await page.getByTestId("workflow-task-return").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("回到申请人修改态");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.return).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,8 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(taskRows).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
@@ -60,9 +60,10 @@
|
||||
1. PC 进入 `审批中心`,或从企业微信消息打开移动审批。
|
||||
2. 用业务类型筛选报销、请假或采购。
|
||||
3. 先查看详情,再执行通过、退回或驳回。
|
||||
4. 退回或驳回必须填写可理解说明。
|
||||
5. 不处理自己提交的单据;系统会阻断自审。
|
||||
6. 管理员账号不等于业务审批人,除非同时具备正式业务审批角色且不是自审。
|
||||
4. 点击通过、退回或驳回后先看确认弹窗:通过会进入下一审批节点或完成审批;退回是让申请人修改后重提;驳回是终止当前流程或进入驳回态。
|
||||
5. 退回或驳回必须填写可理解说明。
|
||||
6. 不处理自己提交的单据;系统会阻断自审。
|
||||
7. 管理员账号不等于业务审批人,除非同时具备正式业务审批角色且不是自审。
|
||||
|
||||
## 4. 财务操作
|
||||
|
||||
|
||||
+8
-5
@@ -272,18 +272,21 @@ R7EP 历史结论:公网、live smoke/OCR、移动矩阵、核心业务流、
|
||||
| 反馈项 | 级别 | 归属 | 本轮处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 通知首页卡片只显示“新”,浏览详情后“新”消失 | PASS | `R7E-15` | 按未读提醒行为接受,不改成“已读/未读”常驻状态 |
|
||||
| 移动详情页左上返回按钮和报销单号贴得太近 | MEDIUM | `R7E-16C` | 保留左上返回,改为 44px 图标返回槽和独立标题槽,避免挤压单号 |
|
||||
| 审批卡片右侧仍只显示“待”,详情处理按钮居中且三按钮分两行 | MEDIUM | `R7E-16C` | 增加审批专用状态槽和在线三按钮同排布局 |
|
||||
| 财务 `/mobile/me` 有入口,但点击后像回首页,付款未启用提示不可发现 | MEDIUM | `BIZ8-12B` | 财务入口定位到首页财务卡片并显示“付款批次未启用”空态 |
|
||||
| 移动详情页左上返回按钮和报销单号贴得太近 | PASS | `R7E-16C` | 用户确认左上返回按钮已有清楚间距 |
|
||||
| 审批卡片右侧仍只显示“待”,详情处理按钮居中且三按钮分两行 | PASS | `R7E-16C` | 用户确认右侧完整显示“待处理”,详情三个按钮在一行 |
|
||||
| 财务 `/mobile/me` 有入口,但点击后像回首页,付款未启用提示不可发现 | PASS | `BIZ8-12B` | 用户确认直接定位到财务卡片并显示“付款批次未启用” |
|
||||
| `wangshunli` 当前没有通知 | INFO | `R7E-15` | 不作为阻断;deeplink 已由此前代表消息验证关闭 |
|
||||
| 审批详情中“退回”和“驳回”语义容易混淆 | MEDIUM | `R7E-16D` | 按产品决策保留短按钮“通过 / 驳回 / 退回”,在二次确认弹窗说明具体后果 |
|
||||
|
||||
本节对应修复已于 2026-05-15 19:31 CST 通过 `bash deploy/trialrun/deploy.sh` 部署到 `https://oa.hyinda.com`,部署基线为 `f945c0b R7E 真机二次反馈移动修复`,`SYNC_ENV=0`,未覆盖远端 `.env`。部署脚本内置远端构建、init、Prisma migration status、`/ready`、external deps smoke、runtime smoke 和公网 bundle 扫描均通过。部署后补充验证:`curl -fsS https://oa.hyinda.com/ready` 通过,`PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs` 扫描 57 个资源且 findings=0,live smoke env readiness 通过,`cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list` 通过 6 项。真机复测仍需用户补充 `SMOKE101` 审批状态和操作区、`SMOKE002` 详情头部间距、`wangzhiling` 财务入口定位。
|
||||
本节对应修复已于 2026-05-15 19:31 CST 通过 `bash deploy/trialrun/deploy.sh` 部署到 `https://oa.hyinda.com`,部署基线为 `f945c0b R7E 真机二次反馈移动修复`,`SYNC_ENV=0`,未覆盖远端 `.env`。部署脚本内置远端构建、init、Prisma migration status、`/ready`、external deps smoke、runtime smoke 和公网 bundle 扫描均通过。部署后补充验证:`curl -fsS https://oa.hyinda.com/ready` 通过,`PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs` 扫描 57 个资源且 findings=0,live smoke env readiness 通过,`cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list` 通过 6 项。2026-05-15 用户补充确认 `SMOKE101` 审批状态和操作区、`SMOKE002` 详情头部间距、`wangzhiling` 财务入口定位均通过;审批动作二次确认进入 `R7E-16D`。
|
||||
|
||||
`R7E-16D` 已于本地实现并验证:PC 审批详情、PC 列表快捷通过、移动审批详情在线动作和离线草稿动作都会先弹二次确认;确认文案解释通过、驳回、退回的后果;移动审批详情主操作改为底部固定操作区。验证命令:`cd client && npm run typecheck`、`cd client && 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`、`cd client && npx playwright test tests/e2e/workflow-action-confirm-ui.spec.ts tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list` 均通过。本项尚未部署到 `https://oa.hyinda.com`,需要后续受控部署后再做真机复测。
|
||||
|
||||
## QAOPS8 Go/No-Go 当前结论
|
||||
|
||||
执行时间:2026-05-15。
|
||||
|
||||
`QAOPS8-17` 当前结论为 `Pause`,原因不是自动化基础门禁失败,也不是 WF8 deeplink 阻断;剩余门禁是 R7E 修复后真机体验反馈、财务角色真机入口复核和最终人工验收结论。该结论用于防止把自动化或单台代表设备证据误解释为正式上线 `Go`。
|
||||
`QAOPS8-17` 当前结论为 `Pause`,原因不是自动化基础门禁失败,也不是 WF8 deeplink 阻断;R7E-16C 和 BIZ8-12B 代表真机反馈已通过,剩余门禁是 R7E-16D 部署后真机复测和最终人工验收结论。该结论用于防止把自动化或单台代表设备证据误解释为正式上线 `Go`。
|
||||
|
||||
| 检查项 | 当前状态 | 处理 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+2
-2
@@ -137,7 +137,7 @@ MIA7 之后,移动 OA 的信息架构以“一级入口少、任务摘要轻
|
||||
|
||||
- 标题显示业务编号或摘要,例如“报销单 BX20260506001”。
|
||||
- 状态、金额、申请人、更新时间作为摘要条。
|
||||
- 主流程操作固定在页头或底部操作区,危险操作靠右或二次确认。
|
||||
- 主流程操作固定在页头或底部操作区,危险或终局动作必须二次确认;审批动作按钮保持“通过 / 驳回 / 退回”短文案,具体后果放在确认弹窗中说明。
|
||||
- 审批流、附件、发票和日志分区展示,避免混在一个长表单里。
|
||||
- 审批、财务和管理员视角可以展示更多审计信息,但普通员工主视角只展示与下一步动作有关的信息。
|
||||
|
||||
@@ -159,7 +159,7 @@ MIA7 之后,移动 OA 的信息架构以“一级入口少、任务摘要轻
|
||||
- 移动首页一级快捷入口使用图标宫格:图标在上、短标题在下,4 项等宽,辅助说明不在小屏常驻展示,避免图标和文字挤压;标题单行省略,不允许逐字换行。
|
||||
- 移动端页面边距、卡片间距、底部导航高度和吸底操作高度使用统一 token;不通过 viewport 缩放字体解决拥挤问题。
|
||||
- 次级入口和信息字段可按最小宽度自动从两列变一列;核心操作按钮必须保持明确可点,不得被内容、安全区或底部栏遮挡。
|
||||
- 报销详情、审批详情等深层 Push 页隐藏底部导航,使用顶部返回和上下文吸底操作,避免底部 Tab 在深层页面假选中。
|
||||
- 报销详情、审批详情等深层 Push 页隐藏底部导航,使用顶部返回和上下文吸底操作,避免底部 Tab 在深层页面假选中;审批详情的通过、驳回、退回等主操作放在底部操作区并先确认。
|
||||
- 企业微信个人应用消息 deeplink 应直达移动待办或业务详情;首次进入如需点击进入工作台,应保持后续可直接回到目标页。
|
||||
- 首页最近通知只展示事件消息,不能重复完整待办卡片;如果某条待办已在首页待处理摘要出现,通知区域不再重复显示同一待办内容。
|
||||
- 移动列表使用卡片和状态标签,首屏优先展示“谁提交、什么事项、金额/天数、当前状态和下一步”。
|
||||
|
||||
@@ -226,6 +226,8 @@
|
||||
|
||||
批量审批不得绕过单条任务的权限、状态和业务校验。
|
||||
|
||||
审批动作按钮使用短文案 `通过 / 驳回 / 退回`,不得在按钮内堆叠长说明。PC 和移动端执行这些动作前必须二次确认,在确认弹窗中解释后果:通过会进入下一审批节点或完成审批并写入历史;驳回会终止当前流程或进入驳回态,申请人通常需要复制或重新发起;退回会回到申请人修改态,保留原单号和审批历史,申请人修改后可重新提交。驳回和退回都必须填写可理解意见。
|
||||
|
||||
### 6.1 自审与转审约束
|
||||
|
||||
- 自审默认禁止,不因申请人同时拥有 `admin`、`manager`、`ceo`、`finance` 或其他角色而放开。
|
||||
|
||||
@@ -14,11 +14,12 @@ R7E-11,审批流程试运行,"审批人按真实路径处理待办,员工查
|
||||
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,财务审批和付款延期试运行,"财务按真实路径复核到财务审批的报销单;本轮不启用 payment_batch_v1,不追付款批次闭环,付款批次作为 P2 单独窗口处理。","1) 财务角色入口正确;2) 到财务审批的报销单可查看;3) payment_batch_v1 关闭时有明确空态或错误契约;4) 管理员不自动获得财务能力;5) 不执行真实付款批次。","流程级验证:财务进入移动入口或台账 -> 查看到财务审批的报销单或付款批次未启用提示 -> 检查管理员无财务执行能力 -> 记录付款批次延期到 P2。",manual|playwright,DONE,DONE,DONE,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;未创建付款批次 未复核 未执行付款 未验证导出。2026-05-14 用户确认 R7E 财务付款策略为先不启用,付款批次延期到 P2;22:08 最新部署后 R7E_BROWSER_FLOW=finance-closeout 通过但 AP API 按 flag_force_off SKIP,R7E_BROWSER_FLOW=mobile 通过并确认员工 审批人 财务移动入口可见且无横向滚动;本行按延期口径收口,不代表付款批次已启用。
|
||||
R7E-15,移动端和真机反馈,"收集 R7E 参与者在移动浏览器和企业微信中的真实体验反馈。","1) 至少覆盖代表性 iPhone/Android 或企业微信设备;2) 记录页面、视口、角色和问题级别;3) 阻断问题进入修复分流;4) 体验优化进入 UX8。","流程级验证:按真机公网测试清单人工复核;必要时补 Playwright 截图。",manual|playwright,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-9""]",2026-05-14 自动化移动浏览器员工 审批人 财务核心入口通过且无横向滚动;用户真机补充:所有设备没有 PC 壳和横向滚动 但通知卡片和待审批卡片右侧状态只露第一个字 归属 R7E-16A;iOS 只能上传 PDF Android 企业微信选择器仍显示拍照和选择文件 归属 R7E-16B;2026-05-14 22:09 修复 5491e92 已部署 DG7+R124 公网移动矩阵 6 passed 且 R7E_BROWSER_FLOW=mobile 通过;2026-05-15 已补代表设备企业微信消息 deeplink 真机通过;2026-05-15 19:31 二次修复 f945c0b 已部署且 /ready public bundle live smoke env 和 DG7+R124 公网移动矩阵 6 passed;本行仍等待 SMOKE101 审批状态与按钮 SMOKE002 详情头部间距 wangzhiling 财务入口和 Android PDF-only 口径在代表设备上的最终反馈。
|
||||
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;2026-05-15 用户确认代表设备企业微信消息 deeplink 可免登录进对应详情 WF8-11 已关闭;二次反馈中通知新标记按未读行为接受,审批状态槽/详情头部/按钮布局归 R7E-16C 且 f945c0b 已部署公网验证通过;财务付款闭环延期到 P2;当前剩余为二次修复真机复核 Android PDF-only 口径确认和 R7E-18 人工验收结论。
|
||||
R7E-15,移动端和真机反馈,"收集 R7E 参与者在移动浏览器和企业微信中的真实体验反馈。","1) 至少覆盖代表性 iPhone/Android 或企业微信设备;2) 记录页面、视口、角色和问题级别;3) 阻断问题进入修复分流;4) 体验优化进入 UX8。","流程级验证:按真机公网测试清单人工复核;必要时补 Playwright 截图。",manual|playwright,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-9""]",2026-05-14 自动化移动浏览器员工 审批人 财务核心入口通过且无横向滚动;用户真机补充:所有设备没有 PC 壳和横向滚动 但通知卡片和待审批卡片右侧状态只露第一个字 归属 R7E-16A;iOS 只能上传 PDF Android 企业微信选择器仍显示拍照和选择文件 归属 R7E-16B;2026-05-14 22:09 修复 5491e92 已部署 DG7+R124 公网移动矩阵 6 passed 且 R7E_BROWSER_FLOW=mobile 通过;2026-05-15 已补代表设备企业微信消息 deeplink 真机通过;2026-05-15 19:31 二次修复 f945c0b 已部署且 /ready public bundle live smoke env 和 DG7+R124 公网移动矩阵 6 passed;用户确认 SMOKE101 审批状态与三按钮布局、SMOKE002 详情头部间距、wangzhiling 财务入口定位均通过;Android 选择器显示拍照按平台限制处理 OA 端 PDF-only 校验已由 R7E-16B 覆盖;本行仍等待 R7E-16D 部署后审批二次确认真机反馈和 R7E-18 阶段验收结论。
|
||||
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;2026-05-15 用户确认代表设备企业微信消息 deeplink 可免登录进对应详情 WF8-11 已关闭;二次反馈中通知新标记按未读行为接受,审批状态槽/详情头部/按钮布局归 R7E-16C 且 f945c0b 已部署公网验证通过并由用户确认通过;财务入口定位归 BIZ8-12B 已由用户确认通过;退回和驳回语义说明与二次确认归 R7E-16D 且本地实现验证完成但未部署;财务付款闭环延期到 P2;当前剩余为 R7E-16D 部署后真机复测和 R7E-18 人工验收结论。
|
||||
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-16C,移动审批和详情头部二次修复,"根据 R7E 真机反馈修复审批卡片右侧只露待字、审批详情操作按钮布局和移动详情返回按钮与单号贴边问题。","1) 移动审批卡片和详情状态完整显示待处理/已处理等文本;2) 审批详情通过/驳回/退回在线操作在 320px 不挤压乱换行;3) 移动详情返回按钮保留左上且与标题至少 12px 间距;4) 不改变审批动作和 deeplink 语义。","流程级验证: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;SMOKE101 真机复核 /mobile/approvals。",npm|playwright|manual,DONE,DONE,DONE,client/src/components/mobile/MobileDetailHeader.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/styles/mobile-touch.css|client/tests/e2e,"[""R7E-16A""]",已部署:2026-05-15 19:31 CST f945c0b 已通过 deploy/trialrun/deploy.sh 发布到 https://oa.hyinda.com 且 SYNC_ENV=0 未覆盖远端 .env;部署后 /ready 通过 public bundle findings=0 live smoke env ready DG7+R124 公网移动矩阵 6 passed;仍需 SMOKE101 真机复核 /mobile/approvals 状态完整和详情按钮布局。回滚路径:恢复移动详情头部和审批操作区样式;不改后端 API 权限 workflow 状态机或企业微信 deeplink。
|
||||
R7E-16C,移动审批和详情头部二次修复,"根据 R7E 真机反馈修复审批卡片右侧只露待字、审批详情操作按钮布局和移动详情返回按钮与单号贴边问题。","1) 移动审批卡片和详情状态完整显示待处理/已处理等文本;2) 审批详情通过/驳回/退回在线操作在 320px 不挤压乱换行;3) 移动详情返回按钮保留左上且与标题至少 12px 间距;4) 不改变审批动作和 deeplink 语义。","流程级验证: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;SMOKE101 真机复核 /mobile/approvals。",npm|playwright|manual,DONE,DONE,DONE,client/src/components/mobile/MobileDetailHeader.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/styles/mobile-touch.css|client/tests/e2e,"[""R7E-16A""]",已部署并真机通过:2026-05-15 19:31 CST f945c0b 已通过 deploy/trialrun/deploy.sh 发布到 https://oa.hyinda.com 且 SYNC_ENV=0 未覆盖远端 .env;部署后 /ready 通过 public bundle findings=0 live smoke env ready DG7+R124 公网移动矩阵 6 passed;用户确认 /mobile/approvals 右侧完整显示待处理 详情三个按钮在一行 且左上返回与标题有清楚间距。回滚路径:恢复移动详情头部和审批操作区样式;不改后端 API 权限 workflow 状态机或企业微信 deeplink。
|
||||
R7E-16D,审批动作二次确认与底部操作统一,"保持通过/驳回/退回短按钮文案,但在 PC 和移动审批动作执行前增加二次确认,明确通过、驳回、退回的业务后果。","1) PC 审批详情通过/驳回/退回先弹确认;2) PC 列表快捷通过先弹确认;3) 移动审批详情通过/驳回/退回和离线草稿先弹确认;4) 确认文案区分通过推进、驳回终止、退回修改后重提;5) 不改后端 workflow 状态机。","流程级验证: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;cd client && npx playwright test tests/e2e/workflow-action-confirm-ui.spec.ts tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list。",npm|playwright,DONE,DONE,DONE,client/src/components/common/ConfirmDialog.tsx|client/src/lib/workflowActionConfirm.ts|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/pages/WorkflowTasksPage.tsx|client/tests/e2e/workflow-action-confirm-ui.spec.ts|client/tests/e2e/r89-mobile-login-navigation.spec.ts|docs/需求-统一审批中心规约.md|docs/产品设计规则.md,"[""R7E-16C""]",本地完成未部署:2026-05-15 已新增共享审批确认文案 helper,PC 详情和列表快捷通过、移动审批详情在线/离线草稿均先弹确认;移动详情动作使用底部固定操作区;验证 client typecheck 通过 build 通过 DG7+R124 6 passed workflow-action-confirm-ui 2 passed R89 3 passed;此前 API 依赖型 mobile-approvals-offline/leave/procurement 在 Vite-only 本地环境会因后端/API 前置不可用失败,未作为本行收口证据。回滚路径:移除二次确认 helper 和调用点 恢复原按钮直接触发 mutation;不改后端 API 权限 workflow 状态机 企业微信 deeplink 或付款批次开关。
|
||||
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""]",已知分流:deeplink 归 WF8 并已代表设备验证关闭;状态槽归 R7E-16A 已部署并自动化验证;Android 选择器归 R7E-16B 按平台限制和 OA PDF-only 校验处理;财务付款批次延期到 P2;本行待 R7E-16 和 R7E-15 真机反馈收口后再关闭。
|
||||
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 功能开发必须另建或延续对应连续合同。
|
||||
|
||||
|
@@ -16,7 +16,7 @@ QAOPS8-13,DORA 交付指标基线,"建立部署频率、变更前置时间、变
|
||||
QAOPS8-14,安全发布前检查,"建立发布前安全检查,包括权限、敏感信息、日志、配置、依赖和等保相关底线。","1) 不泄露 token、密码、票据和个人隐私;2) 权限直连审计有证据;3) 日志不记录敏感明文;4) 高风险项进入 Go/No-Go。","流程级验证:node scripts/qa/audit-docs.mjs --check-links;rg -n 'password|token|secret|密钥|权限直连|敏感' docs scripts issues。",node|rg|manual,DONE,DONE,DONE,scripts/qa/audit-docs.mjs|docs|scripts|issues,"[""QAOPS8-9""]",node scripts/qa/audit-docs.mjs --check-links 为 errors=0 warnings=0;全量 audit-docs 为 errors=0 warnings=8;已知 QA 脚本本地测试哨兵值和未登记 docs 作为 Go/No-Go 复核项,不声明等保测评完成。
|
||||
QAOPS8-15,发布 Runbook 收敛,"把发布前检查、部署步骤、验证步骤、暂停条件和沟通责任收敛到现有 Runbook。","1) 发布步骤可按顺序执行;2) 预检、部署、验证、暂停、回滚边界清楚;3) 不新增冲突文档;4) 用户可审计。","流程级验证:rg -n '发布|预检|部署|验证|暂停|回滚|Runbook' docs/trialrun/发布试运行准备清单.md docs/trialrun/OPS7运维交接Runbook.md docs/试运行与自动化验证手册.md。",rg|manual,DONE,DONE,DONE,docs/trialrun/发布试运行准备清单.md|docs/trialrun/OPS7运维交接Runbook.md|docs/试运行与自动化验证手册.md,"[""QAOPS8-5"",""QAOPS8-6"",""QAOPS8-10""]",rg 复核通过;发布前检查、部署步骤、部署后验证、暂停条件、回滚边界和责任记录均在现有 Runbook 中;未新增冲突文档。
|
||||
QAOPS8-16,回滚演练窗口,"确认回滚演练窗口、负责人、触发条件、数据保护和恢复验证步骤。","1) 窗口和负责人确认;2) 回滚脚本或步骤有只读预检;3) 数据保护优先;4) 演练结果可追溯。","流程级验证:人工确认回滚窗口;必要时先在非生产环境执行。",manual,DONE,DONE,DONE,docs/trialrun/OPS7运维交接Runbook.md,"[""QAOPS8-11"",""QAOPS8-15""]",完成:按用户授权采用推荐方案;窗口暂定 2026-05-14 19:30-20:30 Asia/Shanghai 即 R7E 首日每日收口后;负责人和暂停回滚决策人 wangshunli;范围为非生产恢复演练优先 生产只读回滚 dry run;步骤为备份清单核对 隔离库恢复 /ready 登录核心流程只读验证 生产回滚步骤只读复盘;触发条件为 P0 数据损坏 发布阻断或公网关键门禁失败;停止条件为备份不完整 权限不明确 影响生产或责任人不在线;本行只确认窗口和边界 未执行数据库恢复 生产回滚 服务切换或破坏性命令;真实恢复或生产回滚执行前仍需再次明确批准。
|
||||
QAOPS8-17,上线 Go No-Go 清单,"建立上线或扩大试运行 Go/No-Go 清单,统一本地、公网匿名、带凭据、企业微信真机和人工验收结论。","1) Go、No-Go、Pause 条件明确;2) 阻断问题为 0 或有明确延期;3) R7E 人工验收不被自动化替代;4) 结论写入 Issue 和报告。","流程级验证:人工核对 R7E 结论、QAOPS8 门禁、发布清单和问题清单。",manual,DOING,TODO,TODO,docs/trialrun/发布试运行准备清单.md|docs/trialrun/人工试运行报告.md|issues/*.csv,"[""QAOPS8-15"",""QAOPS8-16""]",当前清单已形成:2026-05-14 最新代码 4b14c92 已部署到试运行环境且 R7E-8 完成;本轮无人工自动化补测结果为公网 /ready 通过 public bundle findings=0 live smoke env ready 授权 PDF 可读,R7E preflight 通过 报销 BX-20260514-0016 创建上传提交审批通过 请假通过 采购 CG-20260514-0005 含附件 PC/移动通过 移动浏览器通过 WeCom UA 模拟通过;审批边界 approval-boundary 通过,申请人和 admin-only 审批接口均被拒绝且经理正常收尾;财务 finance-closeout 诊断为 AP/payment-batches 功能门禁 payment_batch_v1_disabled / flag_force_off,未执行付款闭环;dg7-mobile-information-dedupe 启动 Vite 后 5 passed,前次 127.0.0.1:3000 失败确认为前置问题;2026-05-14 22:05 已追加部署修复提交 5491e92,deploy.sh 远端 build/migration status 通过,public bundle 首次短暂 502 后复核 /ready 200 首页 200 public bundle findings=0,external deps smoke 和 runtime smoke 通过,live readiness 3 passed,真实 OCR 样票 1 passed,DG7+R124 公网移动矩阵 6 passed,R7E mobile/finance-closeout/wecom-ua 分段通过或按 flag_force_off 可解释 SKIP;R7E-14 已按用户确认的付款批次延期到 P2 口径收口;2026-05-15 用户确认代表设备企业微信消息 deeplink 可免登录进入对应详情 WF8-11 和 R7E-9 已关闭;2026-05-15 19:31 二次修复 f945c0b 已部署 deploy.sh 远端构建 init migration ready external deps smoke runtime smoke public bundle 均通过 部署后 /ready 通过 public bundle findings=0 live smoke env ready DG7+R124 公网移动矩阵 6 passed;当前 Go No-Go 仍为 Pause 因 R7E-15 二次修复真机反馈 R7E-18 人工验收结论和 BIZ8-12 财务角色真机复核未完成;明确 R7E 人工验收不被自动化替代。
|
||||
QAOPS8-17,上线 Go No-Go 清单,"建立上线或扩大试运行 Go/No-Go 清单,统一本地、公网匿名、带凭据、企业微信真机和人工验收结论。","1) Go、No-Go、Pause 条件明确;2) 阻断问题为 0 或有明确延期;3) R7E 人工验收不被自动化替代;4) 结论写入 Issue 和报告。","流程级验证:人工核对 R7E 结论、QAOPS8 门禁、发布清单和问题清单。",manual,DOING,TODO,TODO,docs/trialrun/发布试运行准备清单.md|docs/trialrun/人工试运行报告.md|issues/*.csv,"[""QAOPS8-15"",""QAOPS8-16""]",当前清单已形成:2026-05-14 最新代码 4b14c92 已部署到试运行环境且 R7E-8 完成;本轮无人工自动化补测结果为公网 /ready 通过 public bundle findings=0 live smoke env ready 授权 PDF 可读,R7E preflight 通过 报销 BX-20260514-0016 创建上传提交审批通过 请假通过 采购 CG-20260514-0005 含附件 PC/移动通过 移动浏览器通过 WeCom UA 模拟通过;审批边界 approval-boundary 通过,申请人和 admin-only 审批接口均被拒绝且经理正常收尾;财务 finance-closeout 诊断为 AP/payment-batches 功能门禁 payment_batch_v1_disabled / flag_force_off,未执行付款闭环;dg7-mobile-information-dedupe 启动 Vite 后 5 passed,前次 127.0.0.1:3000 失败确认为前置问题;2026-05-14 22:05 已追加部署修复提交 5491e92,deploy.sh 远端 build/migration status 通过,public bundle 首次短暂 502 后复核 /ready 200 首页 200 public bundle findings=0,external deps smoke 和 runtime smoke 通过,live readiness 3 passed,真实 OCR 样票 1 passed,DG7+R124 公网移动矩阵 6 passed,R7E mobile/finance-closeout/wecom-ua 分段通过或按 flag_force_off 可解释 SKIP;R7E-14 已按用户确认的付款批次延期到 P2 口径收口;2026-05-15 用户确认代表设备企业微信消息 deeplink 可免登录进入对应详情 WF8-11 和 R7E-9 已关闭;2026-05-15 19:31 二次修复 f945c0b 已部署 deploy.sh 远端构建 init migration ready external deps smoke runtime smoke public bundle 均通过 部署后 /ready 通过 public bundle findings=0 live smoke env ready DG7+R124 公网移动矩阵 6 passed;用户确认 R7E-16C 审批状态/详情按钮/返回间距和 BIZ8-12B 财务入口定位真机通过;R7E-16D 审批二次确认本地实现完成,client typecheck/build、DG7+R124、workflow-action-confirm-ui、R89 均通过但尚未部署;当前 Go No-Go 仍为 Pause 因 R7E-16D 未部署真机复测和 R7E-18 人工验收结论未完成;明确 R7E 人工验收不被自动化替代。
|
||||
QAOPS8-18,只读审计和排障边界,"明确公网、数据库、日志和 MCP 只读审计边界,避免排障时误写生产或泄露敏感信息。","1) 只读查询边界明确;2) 禁止 INSERT、UPDATE、DELETE;3) 敏感字段脱敏;4) 排障证据只记录摘要和路径。","流程级验证:rg -n 'audit-readonly|只读|INSERT|UPDATE|DELETE|MCP|PostgreSQL' AGENTS.md docs/mcp-tools.md docs/开发交付与变更管理规约.md。",rg|manual,DONE,DONE,DONE,AGENTS.md|docs/mcp-tools.md|docs/开发交付与变更管理规约.md,"[""QAOPS8-14""]",rg 复核通过;AGENTS 限定 audit-readonly 和 PostgreSQL MCP 只读查询;docs/mcp-tools 限定 SELECT 和禁止写操作;开发交付规约已补日志和证据摘要脱敏边界;未执行生产查询或启用新 MCP。
|
||||
QAOPS8-19,R7E 后上线硬化收口,"在 R7E 真实反馈后集中收口上线硬化,更新门禁缺口、问题分流和后续治理优先级。","1) R7E 结论已记录;2) QAOPS8 未完成项有归属;3) 阻断问题有 owner;4) 后续 ORG8、WF8、BIZ8、QAOPS8 顺序可解释。","流程级验证:人工核对 R7E-18、R7E-19、QAOPS8 当前状态和问题清单。",manual,TODO,TODO,TODO,issues/2026-05-13_17-11-44-r7e-human-trialrun.csv|issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv,"[""QAOPS8-17""]",依赖 R7E 人工试运行结论;当前不能提前完成。
|
||||
QAOPS8-20,QAOPS8 收口,"完成 QAOPS8 状态回填、验证摘要、证据索引和中文提交。","1) CSV 校验通过;2) 所有 DONE 有命令或人工证据;3) 文档索引和状态矩阵一致;4) git diff --check 通过;5) 中文提交引用 QAOPS8。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv;node scripts/qa/audit-docs.mjs --check-registry;git diff --check。",python3|node|git|manual,TODO,TODO,TODO,issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md,"[""QAOPS8-19""]",所有执行行完成后才能收口。
|
||||
|
||||
|
@@ -11,9 +11,9 @@ BIZ8-8,财务台账扫读性,"提升财务台账的扫读性,优先解决状
|
||||
BIZ8-9,付款和导出增强,"增强或验证财务付款标记和导出路径,保证权限、状态和审计一致。","1) 只有财务可执行付款或导出;2) 状态转换合法;3) 导出不泄露无权限数据;4) 操作可追溯。","流程级验证:财务付款或导出 -> 普通员工和管理员直连复核 -> 检查状态和权限。",node|bash|manual,DONE,DONE,DONE,server/src|client/src|server/scripts,"[""BIZ8-8""]",完成:仅补强 AP export 权限单测和测试错误契约 不改业务代码;覆盖财务导出成功并写 EXPORT_AP_OPERATIONS 审计 普通员工和 admin-only 无财务权限导出 403 且不写审计;付款批次报销流保持 reviewed 到 paid invoice archived ERP outbox notification audit followup 一致;验证 prisma validate 通过;node --check exports paymentBatches financeLedger 通过;npm test exports.apOperations paymentBatches.reimbursement financeLedger 3 files 8 tests passed;继续冻结直接付款按钮 ERP 实时集成和新付款状态机。
|
||||
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;2026-05-14 修复 5491e92 已部署 public bundle findings=0 DG7+R124 公网移动矩阵 6 passed R7E_BROWSER_FLOW=mobile 通过并确认员工 审批人 财务移动入口可见且无横向滚动;2026-05-15 企业微信消息 deeplink 代表设备已可免登录回 OA;财务入口定位问题归 BIZ8-12B 且 2026-05-15 19:31 f945c0b 已部署 /ready public bundle live smoke env 和 DG7+R124 公网移动矩阵通过;仍缺 wangzhiling 代表真机复核;SMOKE301/SMOKE302 无企业微信不可作为企微成员;保持 DOING 不标 DONE。
|
||||
BIZ8-12,移动端业务入口评估,"评估报销、OCR、财务和项目轻量能力在移动端和企业微信入口的可用性。","1) 移动入口不重复;2) 企业微信入口回 OA;3) 财务敏感能力不误暴露;4) 新增入口进入移动矩阵。","流程级验证:员工和财务角色在移动端进入业务入口 -> 检查报销/OCR/项目状态和权限。",playwright|manual,DONE,DONE,DONE,client/src|docs/产品设计规则.md|docs/trialrun/真机公网测试清单.md,"[""BIZ8-8"",""BIZ8-11""]",自动化和代表真机均已完成:代码路径显示移动首页按权限裁剪报销 OCR 采购 资产入口 财务摘要仅财务角色可见 PC 财务路由在移动上下文回 /mobile;2026-05-14 修复 5491e92 已部署 public bundle findings=0 DG7+R124 公网移动矩阵 6 passed R7E_BROWSER_FLOW=mobile 通过并确认员工 审批人 财务移动入口可见且无横向滚动;2026-05-15 企业微信消息 deeplink 代表设备已可免登录回 OA;2026-05-15 19:31 f945c0b 已部署 /ready public bundle live smoke env 和 DG7+R124 公网移动矩阵通过;用户确认财务入口从 /mobile/me 直接定位到财务卡片并显示付款批次未启用;SMOKE301/SMOKE302 无企业微信不可作为企微成员。
|
||||
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-12B,财务移动入口定位修复,"修复财务角色在 /mobile/me 点击财务入口后只像回首页一样无法定位付款未启用提示的问题。","1) 财务入口仅财务角色可见;2) 点击后进入或定位到 /mobile 的财务卡片;3) 付款批次关闭时 mobile-finance-disabled-state 可见且文案清楚;4) 不开启付款批次或修改财务权限。","流程级验证: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;wangzhiling 真机从 /mobile/me 点击财务入口。",npm|playwright|manual,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/tests/e2e,"[""BIZ8-12A""]",已部署:2026-05-15 19:31 CST f945c0b 已通过 deploy/trialrun/deploy.sh 发布到 https://oa.hyinda.com 且 SYNC_ENV=0 未覆盖远端 .env;部署后 /ready 通过 public bundle findings=0 live smoke env ready DG7+R124 公网移动矩阵 6 passed;仍需 wangzhiling 真机从 /mobile/me 点击财务入口确认定位到财务卡片和付款批次未启用提示。回滚路径:恢复财务入口旧跳转;不改后端 API 权限 payment_batch_v1 或付款状态机。
|
||||
BIZ8-12B,财务移动入口定位修复,"修复财务角色在 /mobile/me 点击财务入口后只像回首页一样无法定位付款未启用提示的问题。","1) 财务入口仅财务角色可见;2) 点击后进入或定位到 /mobile 的财务卡片;3) 付款批次关闭时 mobile-finance-disabled-state 可见且文案清楚;4) 不开启付款批次或修改财务权限。","流程级验证: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;wangzhiling 真机从 /mobile/me 点击财务入口。",npm|playwright|manual,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/tests/e2e,"[""BIZ8-12A""]",已部署并真机通过:2026-05-15 19:31 CST f945c0b 已通过 deploy/trialrun/deploy.sh 发布到 https://oa.hyinda.com 且 SYNC_ENV=0 未覆盖远端 .env;部署后 /ready 通过 public bundle findings=0 live smoke env ready DG7+R124 公网移动矩阵 6 passed;用户确认财务入口从 /mobile/me 直接定位到财务卡片并显示付款批次未启用。回滚路径:恢复财务入口旧跳转;不改后端 API 权限 payment_batch_v1 或付款状态机。
|
||||
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 行。
|
||||
|
||||
|
在新工单中引用
屏蔽一个用户