R30-1 R30-9: 建立零人工发布门禁并补齐高频回归
这个提交包含在:
@@ -150,7 +150,7 @@ export function FeatureFlagsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="功能开关"
|
||||
subtitle="统一治理 ERP、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
subtitle="统一治理外部桥接、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
uploadPurchaseAttachment,
|
||||
withdrawPurchaseRequest,
|
||||
type PurchaseErpBinding,
|
||||
type PurchaseRequestDetail,
|
||||
type PurchaseRequestErpRequeueTarget,
|
||||
type PurchaseRequestErpTrack,
|
||||
type PurchaseRequestItemPayload,
|
||||
@@ -514,7 +515,23 @@ export function PurchaseRequestsPage() {
|
||||
archivedFiles: archiveOrderQuery.data?.archiveOrder?.archivedFiles ?? [],
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (data) => {
|
||||
if (selectedId) {
|
||||
setArchivePoNo(data.archiveOrder.poNo ?? "");
|
||||
setArchiveContractNo(data.archiveOrder.contractNo ?? "");
|
||||
queryClient.setQueryData<{ archiveOrder: Awaited<ReturnType<typeof fetchPurchaseOrderArchive>>["archiveOrder"] }>(
|
||||
["purchase-requests", "archive-order", selectedId],
|
||||
{ archiveOrder: data.archiveOrder },
|
||||
);
|
||||
queryClient.setQueryData<PurchaseRequestDetail | undefined>(["purchase-requests", "detail", selectedId], (current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
archiveOrder: data.archiveOrder,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
||||
@@ -1227,13 +1244,13 @@ export function PurchaseRequestsPage() {
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-zinc-900">采购 ERP 同步轨道</h4>
|
||||
<p className="text-sm text-zinc-500">展示 Commitment / Accrual / PO/合同摘要三段 ERP 同步状态,失败后可在此重试。</p>
|
||||
<h4 className="text-base font-semibold text-zinc-900">采购桥接轨道</h4>
|
||||
<p className="text-sm text-zinc-500">展示 Commitment / Accrual / PO/合同摘要三段外部桥接状态,失败后可在此重试。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{erpTrackQuery.isLoading ? (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 轨道加载中...</p>
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">桥接轨道加载中...</p>
|
||||
) : erpTrackQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(erpTrackQuery.error)}</p>
|
||||
) : erpTrack ? (
|
||||
|
||||
@@ -252,7 +252,7 @@ export function ReimbursementDetailPage() {
|
||||
]);
|
||||
setActionError("");
|
||||
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
|
||||
toast.success(`ERP 导出${suffix}`);
|
||||
toast.success(`财务桥接出队${suffix}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isApiError(error)) {
|
||||
@@ -268,8 +268,8 @@ export function ReimbursementDetailPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError("ERP 导出失败,请稍后重试");
|
||||
toast.error("ERP 导出失败,请稍后重试");
|
||||
setActionError("财务桥接出队失败,请稍后重试");
|
||||
toast.error("财务桥接出队失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -461,11 +461,11 @@ export function ReimbursementDetailPage() {
|
||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 导出</h3>
|
||||
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。</p>
|
||||
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
|
||||
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
|
||||
</div>
|
||||
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
|
||||
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
|
||||
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -478,7 +478,7 @@ export function ReimbursementDetailPage() {
|
||||
erpPreviewQuery.error.details,
|
||||
erpPreviewQuery.error.message,
|
||||
)}`
|
||||
: "加载 ERP 导出预览失败"}
|
||||
: "加载财务桥接预览失败"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const ITEMS = [
|
||||
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
||||
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
||||
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
||||
{ to: "/system/integrations/erp", title: "ERP 集成", description: "查看备用集成能力与出入口。" },
|
||||
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
|
||||
];
|
||||
|
||||
export function SystemSettingsPage() {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
const ADMIN = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
};
|
||||
|
||||
test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇总", async ({ page }) => {
|
||||
const adminClient = await createAuthenticatedApiClient(ADMIN);
|
||||
const suffix = Date.now();
|
||||
const announcementTitle = `Playwright 公告 ${suffix}`;
|
||||
const presenceNote = `下午远程处理发布回归 ${suffix}`;
|
||||
|
||||
try {
|
||||
await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/announcements", {
|
||||
title: announcementTitle,
|
||||
summary: "用于验证公告可见性与 Portal 第二阅读层展示。",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await page.goto("/announcements");
|
||||
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
|
||||
await page.goto("/team");
|
||||
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
|
||||
await page.locator("select.select-std").first().selectOption("remote");
|
||||
await page.getByPlaceholder("可选备注,如下午外出办事").fill(presenceNote);
|
||||
await page.getByRole("button", { name: "保存状态" }).click();
|
||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/team");
|
||||
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "不在岗" }).click();
|
||||
await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
|
||||
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
|
||||
} finally {
|
||||
await adminClient.dispose();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, request as playwrightRequest, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
import { TEST_IDS } from "./testIds";
|
||||
|
||||
export const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000";
|
||||
|
||||
export type E2EUser = {
|
||||
@@ -74,15 +76,22 @@ async function readJson<T>(response: { ok(): boolean; status(): number; text():
|
||||
|
||||
export async function login(page: Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.getByTestId(TEST_IDS.login.identifier).fill(user.identifier);
|
||||
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
|
||||
await page.getByTestId(TEST_IDS.login.submit).click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
}
|
||||
|
||||
export async function logout(page: Page, userDisplayName: string) {
|
||||
export async function logout(page: Page, userDisplayName?: string) {
|
||||
try {
|
||||
await page.getByRole("button", { name: userDisplayName, exact: true }).first().click({ force: true });
|
||||
const profileTrigger = page.getByTestId(TEST_IDS.topbar.profileTrigger).first();
|
||||
if (await profileTrigger.isVisible().catch(() => false)) {
|
||||
await profileTrigger.click({ force: true });
|
||||
} else if (userDisplayName) {
|
||||
await page.getByRole("button", { name: userDisplayName, exact: true }).first().click({ force: true });
|
||||
} else {
|
||||
throw new Error("profile trigger not visible");
|
||||
}
|
||||
await expect(page.getByRole("menuitem", { name: "退出登录" })).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByRole("menuitem", { name: "退出登录" }).click();
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { TEST_IDS } from "./testIds";
|
||||
|
||||
export async function openBackupModulesIfNeeded(page: Page) {
|
||||
const summary = page.locator("summary").filter({ hasText: "备用模块" }).first();
|
||||
if (await summary.count()) {
|
||||
@@ -22,3 +24,18 @@ export async function openNavLink(page: Page, name: string | RegExp): Promise<Lo
|
||||
await expect(link).toBeVisible();
|
||||
return link;
|
||||
}
|
||||
|
||||
|
||||
export async function openProfileSettings(page: Page, userDisplayName?: string) {
|
||||
const profileTrigger = page.getByTestId(TEST_IDS.topbar.profileTrigger).first();
|
||||
if (await profileTrigger.isVisible().catch(() => false)) {
|
||||
await profileTrigger.click();
|
||||
} else if (userDisplayName) {
|
||||
await page.getByRole("button", { name: userDisplayName, exact: true }).first().click();
|
||||
} else {
|
||||
throw new Error("无法定位个人设置入口:缺少 topbar profile trigger");
|
||||
}
|
||||
await expect(page.getByRole("menuitem", { name: "个人设置" })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "个人设置" }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/profile/);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const TEST_IDS = {
|
||||
login: {
|
||||
identifier: "login-identifier",
|
||||
password: "login-password",
|
||||
submit: "login-submit",
|
||||
},
|
||||
topbar: {
|
||||
profileTrigger: "topbar-profile-trigger",
|
||||
},
|
||||
workflow: {
|
||||
tasksTable: "workflow-tasks-table",
|
||||
taskRow: "workflow-task-row",
|
||||
detailLink: "workflow-task-detail-link",
|
||||
summary: "workflow-task-summary",
|
||||
approve: "workflow-task-approve",
|
||||
},
|
||||
audit: {
|
||||
export: "audit-export",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function formatDate(offsetDays: number) {
|
||||
const value = new Date();
|
||||
value.setUTCDate(value.getUTCDate() + offsetDays);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
async function selectFirstEnabledOption(locator: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const optionValues = await locator.locator("option").evaluateAll((options) =>
|
||||
options
|
||||
.map((option) => ({
|
||||
value: (option as HTMLOptionElement).value,
|
||||
disabled: (option as HTMLOptionElement).disabled,
|
||||
}))
|
||||
.filter((option) => option.value && !option.disabled)
|
||||
.map((option) => option.value),
|
||||
);
|
||||
return optionValues[0] ?? "";
|
||||
})
|
||||
.not.toBe("");
|
||||
|
||||
const [firstValue] = await locator.locator("option").evaluateAll((options) =>
|
||||
options
|
||||
.map((option) => ({
|
||||
value: (option as HTMLOptionElement).value,
|
||||
disabled: (option as HTMLOptionElement).disabled,
|
||||
}))
|
||||
.filter((option) => option.value && !option.disabled)
|
||||
.map((option) => option.value),
|
||||
);
|
||||
|
||||
await locator.selectOption(firstValue);
|
||||
}
|
||||
|
||||
async function createLeaveDraft(page: Page, startDate: string, endDate: string, reason: string) {
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
|
||||
);
|
||||
|
||||
await selectFirstEnabledOption(page.locator("#leave-type"));
|
||||
await page.locator("#leave-start-date").fill(startDate);
|
||||
await page.locator("#leave-end-date").fill(endDate);
|
||||
await page.locator("#leave-start-session").selectOption("am");
|
||||
await page.locator("#leave-end-session").selectOption("pm");
|
||||
await page.locator("#leave-reason").fill(reason);
|
||||
await page.getByRole("button", { name: "创建草稿" }).click();
|
||||
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const leaveRequestId = createPayload.data?.id;
|
||||
if (!leaveRequestId) {
|
||||
throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
return leaveRequestId;
|
||||
}
|
||||
|
||||
async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
|
||||
return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
|
||||
}
|
||||
|
||||
async function withdrawLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
|
||||
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
|
||||
}
|
||||
|
||||
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
|
||||
const firstDate = formatDate(baseOffset);
|
||||
const secondDate = formatDate(baseOffset + 7);
|
||||
|
||||
try {
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "请假")).click();
|
||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||
|
||||
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
|
||||
await submitLeave(employeeClient.api, firstLeaveId);
|
||||
await page.goto("/leave-requests");
|
||||
const firstRow = page.getByRole("row").filter({ hasText: firstDate }).first();
|
||||
await expect(firstRow).toContainText("审批中", { timeout: 30_000 });
|
||||
|
||||
await withdrawLeave(employeeClient.api, firstLeaveId);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(firstRow).toContainText("草稿", { timeout: 30_000 });
|
||||
|
||||
const secondLeaveId = await createLeaveDraft(page, secondDate, secondDate, `Playwright 审批请假 ${Date.now()}`);
|
||||
const secondSubmit = await submitLeave(employeeClient.api, secondLeaveId);
|
||||
await page.goto("/leave-requests");
|
||||
const secondRow = page.getByRole("row").filter({ hasText: secondDate }).first();
|
||||
await expect(secondRow).toContainText("审批中", { timeout: 30_000 });
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await (await openNavLink(page, "待我处理")).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
|
||||
await expect(taskRow).toBeVisible({ timeout: 30_000 });
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||
await page.getByTestId(TEST_IDS.workflow.approve).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.summary)).toContainText(/状态:(completed|已完成)/);
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||||
await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { login } from "./helpers/auth";
|
||||
import { assertBackupModulesCollapsed } from "./helpers/smokeAssertions";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
const ADMIN = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
};
|
||||
|
||||
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page }) => {
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await assertBackupModulesCollapsed(page);
|
||||
|
||||
await page.goto("/system/workspace");
|
||||
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
|
||||
await expect(page.getByText("产品模式")).toBeVisible();
|
||||
|
||||
await page.goto("/system/settings");
|
||||
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.goto("/system/feature-flags");
|
||||
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
|
||||
await expect(page.getByTestId("feature-flags-table")).toBeVisible();
|
||||
|
||||
await page.goto("/system/workflow-definitions");
|
||||
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
|
||||
await expect(page.getByText("版本列表")).toBeVisible();
|
||||
|
||||
await page.goto("/audit-logs");
|
||||
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
||||
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, login } from "./helpers/auth";
|
||||
import { openProfileSettings } from "./helpers/navigation";
|
||||
|
||||
function mockWecomConfigPayload() {
|
||||
return {
|
||||
@@ -44,7 +45,7 @@ test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async (
|
||||
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/settings/profile");
|
||||
await openProfileSettings(page, EMPLOYEE.name);
|
||||
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
||||
await expect(page.getByText("接入状态", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户