文件
hyb-oa/client/tests/e2e/r75-automation-gap-fill.spec.ts
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

438 行
16 KiB
TypeScript

import { type APIResponse } from "@playwright/test";
import { createRequire } from "node:module";
import { expect, test } from "./helpers/fixtures";
import {
ADMIN,
EMPLOYEE,
FINANCE,
MANAGER,
apiGetData,
apiPostData,
createAuthenticatedApiClient,
login,
logout,
} from "./helpers/auth";
type FeatureFlagItem = {
flagKey: string;
overrideMode: "inherit" | "force_on" | "force_off";
rolloutType: "global" | "department_allowlist";
departmentIds: string[];
note: string;
version: number;
effectiveEnabled: boolean;
};
type NotificationListResponse = {
unreadCount: number;
items: Array<{
id: string;
title: string;
body: string | null;
targetType: string | null;
targetId: string | null;
readAt: string | null;
createdAt: string;
}>;
};
type DepartmentListResponse = {
items: Array<{
id: string;
name: string;
}>;
};
type EmployeeSummary = {
id: string;
employeeNo: string;
name: string;
email: string;
employmentStatus: string | null;
status: string;
};
type ExpenseTypeListResponse = {
items: Array<{
id: string;
code: string;
enabled: boolean;
name: string;
}>;
};
type InvoiceUploadResponse = {
invoicePoolId: string;
status: string;
fileSha256: string;
};
type InvoiceDetailResponse = {
id: string;
status: string;
};
type SubmittedReimbursementSeed = {
reimbursementId: string;
formNumber: string;
currentTaskId: string;
};
type AuthedApi = Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"];
const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url));
async function readApiData<T>(response: APIResponse) {
const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } };
if (!response.ok()) {
throw new Error(`${payload.error?.code ?? response.status()}: ${payload.error?.message ?? "request_failed"}`);
}
return payload.data as T;
}
async function ensureFeatureFlags(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], keys: string[]) {
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
for (const key of keys) {
const item = list.items.find((flag) => flag.flagKey === key);
if (!item) {
throw new Error(`Missing feature flag: ${key}`);
}
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
continue;
}
await readApiData<{ item: FeatureFlagItem }>(
await api.put(`/api/system/feature-flags/${key}`, {
data: {
overrideMode: "force_on",
rolloutType: "global",
departmentIds: [],
note: "playwright r75",
expectedVersion: item.version,
},
}),
);
}
}
async function createSubmittedReimbursement(api: AuthedApi, label: string): Promise<SubmittedReimbursementSeed> {
const reimbursementDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
const description = `Playwright 通知补盲 ${label}`;
const created = await apiPostData<{ id: string }>(api, "/api/reimbursements", {
reimbursementDate,
description,
});
const expenseTypes = await apiGetData<ExpenseTypeListResponse>(api, "/api/expense-types?enabled=true");
const expenseTypeId = expenseTypes.items.find((item) => item.enabled && item.code === "office_postal")?.id;
if (!expenseTypeId) {
throw new Error("未找到合成通知用例必需的邮寄快递费用类型");
}
const item = await apiPostData<{ id: string }>(api, `/api/reimbursements/${created.id}/items`, {
expenseTypeId,
expenseDate: reimbursementDate,
amount: "10.00",
description,
});
const document = await requireServer("pdf-lib").PDFDocument.create();
document.addPage().drawText(`Synthetic R75 notification ${label} ${Date.now()}`, { x: 40, y: 700 });
const upload = await readApiData<InvoiceUploadResponse>(
await api.post("/api/invoice-pool/upload", {
multipart: {
file: {
name: `notify-${label}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(await document.save()),
},
ocr_type: "general",
},
}),
);
await expect
.poll(
async () => {
const invoice = await apiGetData<InvoiceDetailResponse>(api, `/api/invoice-pool/${upload.invoicePoolId}`);
return invoice.status;
},
{
message: `等待票据 ${upload.invoicePoolId} 进入可绑定状态`,
timeout: 90_000,
},
)
.toBe("available");
await apiPostData(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, {
reimbursementId: created.id,
linkedItemId: item.id,
});
const response = await api.post(`/api/reimbursements/${created.id}/submit`, { data: {} });
const payload = await response.json() as { data?: SubmittedReimbursementSeed; error?: { code?: string; details?: { reasonCode?: string } } };
expect(response.ok(), JSON.stringify({ status: response.status(), code: payload.error?.code, reasonCode: payload.error?.details?.reasonCode, fields: Object.keys(payload.error?.details ?? {}) })).toBe(true);
expect(payload.data).toBeDefined();
const submitted = payload.data!;
return {
reimbursementId: submitted.reimbursementId,
formNumber: submitted.formNumber,
currentTaskId: submitted.currentTaskId,
};
}
async function settleWorkflowTaskChain(managerApi: AuthedApi, financeApi: AuthedApi, taskId: string | null) {
if (!taskId) {
return;
}
try {
const approved = await apiPostData<{ nextTaskId: string | null }>(managerApi, `/api/workflow/tasks/${taskId}/approve`, {});
if (approved.nextTaskId) {
await apiPostData(financeApi, `/api/workflow/tasks/${approved.nextTaskId}/approve`, {}).catch(() => undefined);
}
} catch {
// 清理阶段不覆盖主断言结果。
}
}
async function fetchEmployeeByKeyword(
api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"],
keyword: string,
) {
const encoded = encodeURIComponent(keyword);
const payload = await apiGetData<{ items: EmployeeSummary[] }>(
api,
`/api/employees?page=1&pageSize=20&scope=management&q=${encoded}`,
);
return payload.items.find((item) => item.employeeNo === keyword || item.email === keyword) ?? null;
}
test.describe("R75 自动化补盲", () => {
test.describe.configure({ mode: "serial" });
test("E2E R75 考勤补盲:OA 自建打卡关闭 + 统一考勤入口", async ({ page }) => {
test.setTimeout(240_000);
const adminClient = await createAuthenticatedApiClient(ADMIN);
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
try {
await ensureFeatureFlags(adminClient.api, ["attendance_unified_v1"]);
const clockInResponse = await employeeClient.api.post("/api/attendance/clock-in", { data: {} });
expect(clockInResponse.status()).toBe(409);
const clockInPayload = (await clockInResponse.json()) as { error?: { details?: { featureFlag?: string } } };
expect(clockInPayload.error?.details?.featureFlag).toBe("attendance_v1_disabled");
await login(page, EMPLOYEE);
await page.goto("/attendance");
await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible();
await expect(page.getByText("企业微信主办打卡")).toBeVisible();
await expect(page.getByRole("button", { name: /点击打上班卡|点击下班|重新打下班卡/ })).toHaveCount(0);
await expect(page.getByText("本月打卡记录")).toHaveCount(0);
await page.getByRole("tab", { name: "修正申请" }).click();
await expect(page.getByRole("heading", { name: "企微处理入口" })).toBeVisible();
await expect(page.getByRole("button", { name: "提交应急修正" })).toHaveCount(0);
await logout(page, EMPLOYEE.name);
await login(page, ADMIN);
await page.goto("/attendance");
await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible();
await expect(page.getByText("OA 展示月度摘要、异常解释、同步状态和审计记录")).toBeVisible();
await expect(page.getByRole("tab", { name: "修正申请" })).toBeVisible();
await page.getByRole("tab", { name: "修正申请" }).click();
await expect(page.getByRole("heading", { name: "OA 应急修正" })).toBeVisible();
await expect(page.getByRole("tab", { name: "团队考勤" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "保存补卡" })).toHaveCount(0);
} finally {
await Promise.all([employeeClient.dispose(), adminClient.dispose()]);
}
});
test("E2E R75 通知补盲:未读筛选、单条已读、全部已读与跳转", async ({ page }) => {
test.setTimeout(240_000);
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const managerClient = await createAuthenticatedApiClient(MANAGER);
const financeClient = await createAuthenticatedApiClient(FINANCE);
const suffix = Date.now();
let firstTaskId: string | null = null;
let secondTaskId: string | null = null;
try {
await apiPostData<{ updatedCount: number }>(managerClient.api, "/api/notifications/mark-all-read", {});
const firstReimbursement = await createSubmittedReimbursement(employeeClient.api, `A-${suffix}`);
const secondReimbursement = await createSubmittedReimbursement(employeeClient.api, `B-${suffix}`);
firstTaskId = firstReimbursement.currentTaskId;
secondTaskId = secondReimbursement.currentTaskId;
await expect
.poll(
async () => {
const payload = await apiGetData<NotificationListResponse>(
managerClient.api,
"/api/notifications?unreadOnly=1&type=workflow.task_assigned&page=1&pageSize=20",
);
return payload.items.filter(
(item) =>
item.title.includes(firstReimbursement.formNumber) ||
item.body?.includes(firstReimbursement.formNumber) ||
item.title.includes(secondReimbursement.formNumber) ||
item.body?.includes(secondReimbursement.formNumber),
).length;
},
{
timeout: 60_000,
},
)
.toBeGreaterThanOrEqual(2);
await login(page, MANAGER);
await page.goto("/notifications?unreadOnly=1&type=workflow.task_assigned&page=1");
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
const firstRow = page.getByTestId("notification-row").filter({ hasText: firstReimbursement.formNumber }).first();
const secondRow = page.getByTestId("notification-row").filter({ hasText: secondReimbursement.formNumber }).first();
await expect(firstRow).toContainText("未读");
await expect(secondRow).toContainText("未读");
await firstRow.getByTestId("notification-target-link").click();
await expect(page).toHaveURL(/\/workflow\/tasks\/[0-9a-f-]+/);
await page.goto("/notifications?unreadOnly=1&type=workflow.task_assigned&page=1");
await expect(firstRow).toBeVisible();
await firstRow.getByTestId("notification-mark-read").click();
await expect(page.getByTestId("notification-row").filter({ hasText: firstReimbursement.formNumber })).toHaveCount(0);
await page.getByTestId("notifications-mark-all-read").click();
await expect(page.getByText("已全部标为已读")).toBeVisible();
await expect(page.getByTestId("notification-row").filter({ hasText: secondReimbursement.formNumber })).toHaveCount(0);
await page.getByTestId("notifications-filter-all").click();
await expect(page.getByTestId("notification-row").filter({ hasText: secondReimbursement.formNumber }).first()).toContainText("已读");
} finally {
await settleWorkflowTaskChain(managerClient.api, financeClient.api, firstTaskId);
await settleWorkflowTaskChain(managerClient.api, financeClient.api, secondTaskId);
await Promise.all([employeeClient.dispose(), managerClient.dispose(), financeClient.dispose()]);
}
});
test("E2E R75 生命周期补盲:创建临时员工后发起入职并转正", async ({ page }) => {
test.setTimeout(240_000);
const adminClient = await createAuthenticatedApiClient(ADMIN);
const suffix = Date.now();
const employeeNo = `PW-R75-${suffix}`;
const email = `pw-r75-${suffix}@hyinda.com`;
const phone = `139${String(suffix).slice(-8)}`;
let createdEmployeeId: string | null = null;
try {
await ensureFeatureFlags(adminClient.api, ["employee_lifecycle_v1"]);
const departments = await apiGetData<DepartmentListResponse>(adminClient.api, "/api/departments?includeArchived=0");
const departmentId = departments.items[0]?.id;
if (!departmentId) {
throw new Error("未找到可用部门,无法创建生命周期测试员工");
}
const created = await apiPostData<EmployeeSummary>(adminClient.api, "/api/users", {
employeeNo,
name: `Playwright 生命周期 ${suffix}`,
phone,
email,
password: "PlaywrightTemp123!",
departmentId,
roles: ["employee"],
});
createdEmployeeId = created.id;
await login(page, ADMIN);
await page.goto("/people");
await expect(page.getByRole("heading", { name: "人员管理" })).toBeVisible();
await page.getByPlaceholder("搜索姓名、工号、手机号、邮箱").fill(employeeNo);
const row = page.getByRole("row").filter({ hasText: employeeNo }).first();
await expect(row).toBeVisible();
await row.getByRole("button", { name: "发起入职" }).click();
await expect(page.getByText("已发起入职流程")).toBeVisible();
await expect(row).toContainText("待入职");
await row.getByRole("button", { name: "转正" }).click();
await expect(page.getByText("已完成转正")).toBeVisible();
await expect(row).toContainText("在职");
await expect
.poll(async () => {
const employee = await fetchEmployeeByKeyword(adminClient.api, employeeNo);
return employee?.employmentStatus ?? null;
})
.toBe("active");
} finally {
if (createdEmployeeId) {
await apiPostData<{ id: string; status: string }>(adminClient.api, `/api/users/${createdEmployeeId}/archive`, {});
}
await adminClient.dispose();
}
});
test("E2E R75 治理补盲:功能开关备注可保存并恢复", async ({ page }) => {
test.setTimeout(180_000);
const adminClient = await createAuthenticatedApiClient(ADMIN);
const flagKey = "ai_assist";
const updatedNote = `Playwright R75 配置写回 ${Date.now()}`;
let originalFlag: FeatureFlagItem | null = null;
try {
const list = await apiGetData<{ items: FeatureFlagItem[] }>(adminClient.api, "/api/system/feature-flags");
originalFlag = list.items.find((item) => item.flagKey === flagKey) ?? null;
if (!originalFlag) {
throw new Error(`未找到功能开关 ${flagKey}`);
}
await login(page, ADMIN);
await page.goto("/system/feature-flags");
await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible();
const row = page.getByTestId(`feature-flag-row-${flagKey}`);
await expect(row).toBeVisible();
await row.locator("textarea").fill(updatedNote);
await row.getByTestId(`feature-flag-save-${flagKey}`).click();
await expect(page.getByText("开关已更新")).toBeVisible();
await expect(row.locator("textarea")).toHaveValue(updatedNote);
await expect
.poll(async () => {
const latest = await apiGetData<{ items: FeatureFlagItem[] }>(adminClient.api, "/api/system/feature-flags");
return latest.items.find((item) => item.flagKey === flagKey)?.note ?? "";
})
.toBe(updatedNote);
} finally {
if (originalFlag) {
const latest = await apiGetData<{ items: FeatureFlagItem[] }>(adminClient.api, "/api/system/feature-flags");
const current = latest.items.find((item) => item.flagKey === flagKey);
if (current) {
await readApiData<{ item: FeatureFlagItem }>(
await adminClient.api.put(`/api/system/feature-flags/${flagKey}`, {
data: {
overrideMode: originalFlag.overrideMode,
rolloutType: originalFlag.rolloutType,
departmentIds: originalFlag.departmentIds,
note: originalFlag.note,
expectedVersion: current.version,
},
}),
);
}
}
await adminClient.dispose();
}
});
});