完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -1,49 +1,5 @@
|
||||
import { devices, expect, test } from "@playwright/test";
|
||||
import { apiGetData, apiPostData, createAuthenticatedApiClient, EMPLOYEE, MANAGER, type E2EUser } from "./helpers/auth";
|
||||
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./helpers/workflow";
|
||||
|
||||
type LeaveTypeListResponse = {
|
||||
items: Array<{ id: string; requiresAttachment: boolean }>;
|
||||
};
|
||||
|
||||
type WorkflowSubmitResponse = {
|
||||
workflowInstanceId: string;
|
||||
};
|
||||
|
||||
function formatDate(offsetDays: number) {
|
||||
const value = new Date();
|
||||
value.setUTCDate(value.getUTCDate() + offsetDays);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function createSubmittedLeaveViaApi(input: { reasonPrefix: string }) {
|
||||
const client = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const leaveTypes = await apiGetData<LeaveTypeListResponse>(client.api, "/api/leave-types?enabled=true");
|
||||
const leaveType = leaveTypes.items.find((item) => !item.requiresAttachment) ?? leaveTypes.items[0];
|
||||
if (!leaveType) {
|
||||
throw new Error("缺少可用于移动审批离线测试的请假类型");
|
||||
}
|
||||
|
||||
const offset = 3600 + (Date.now() % 1200);
|
||||
const startDate = formatDate(offset);
|
||||
const created = await apiPostData<{ id: string }>(client.api, "/api/leave-requests", {
|
||||
leaveTypeId: leaveType.id,
|
||||
startDate,
|
||||
endDate: startDate,
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
reason: `${input.reasonPrefix}-${Date.now()}`,
|
||||
});
|
||||
await apiPostData<WorkflowSubmitResponse>(client.api, `/api/leave-requests/${created.id}/submit`, {});
|
||||
|
||||
return {
|
||||
client,
|
||||
leaveRequest: {
|
||||
requestId: created.id,
|
||||
startDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
import { MANAGER, type E2EUser } from "./helpers/auth";
|
||||
|
||||
async function loginMobile(page: import("@playwright/test").Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
@@ -87,100 +43,15 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectMobileApprovalDetailUrl(page: import("@playwright/test").Page, taskId: string) {
|
||||
const encodedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/approvals(?:/${encodedTaskId}|\\?taskId=${encodedTaskId})`));
|
||||
}
|
||||
|
||||
async function returnToMobileApprovalList(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
|
||||
await expect(page).toHaveURL(/\/mobile\/approvals(?:\?|$)/);
|
||||
}
|
||||
|
||||
// Daily leave is owned by WeCom; its read-only boundary is covered by leave-flow.spec.ts.
|
||||
test.use({
|
||||
...devices["iPhone 13"],
|
||||
serviceWorkers: "block",
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1",
|
||||
});
|
||||
|
||||
test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }) => {
|
||||
const created = await createSubmittedLeaveViaApi({
|
||||
reasonPrefix: "Playwright移动审批通过",
|
||||
});
|
||||
const managerApi = await createAuthenticatedApiClient(MANAGER);
|
||||
|
||||
try {
|
||||
const task = await waitForWorkflowTask(managerApi.api, {
|
||||
entityId: created.leaveRequest.requestId,
|
||||
entityType: "leave",
|
||||
});
|
||||
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expectMobileApprovalDetailUrl(page, task.id);
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toContainText("请假");
|
||||
await waitForWorkflowTaskCache(page, task.id);
|
||||
|
||||
await page.context().setOffline(true);
|
||||
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);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.all([created.client.dispose(), managerApi.dispose()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
const created = await createSubmittedLeaveViaApi({
|
||||
reasonPrefix: "Playwright移动审批冲突",
|
||||
});
|
||||
const managerApi = await createAuthenticatedApiClient(MANAGER);
|
||||
|
||||
try {
|
||||
const task = await waitForWorkflowTask(managerApi.api, {
|
||||
entityId: created.leaveRequest.requestId,
|
||||
entityType: "leave",
|
||||
});
|
||||
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expectMobileApprovalDetailUrl(page, task.id);
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toContainText("请假");
|
||||
await waitForWorkflowTaskCache(page, task.id);
|
||||
|
||||
await page.context().setOffline(true);
|
||||
await page.getByTestId("mobile-approvals-more").click();
|
||||
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);
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 0,失败 0,冲突 1/)).toBeVisible({ timeout: 30_000 });
|
||||
await returnToMobileApprovalList(page);
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误", { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "丢弃草稿" })).toBeVisible();
|
||||
} finally {
|
||||
await Promise.all([created.client.dispose(), managerApi.dispose()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail closed", async ({ page }) => {
|
||||
const taskId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
@@ -271,6 +142,7 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
|
||||
}],
|
||||
};
|
||||
|
||||
let signedIn = false;
|
||||
const fulfillData = (route: import("@playwright/test").Route, data: unknown, headers?: Record<string, string>) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -289,6 +161,10 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
|
||||
loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [],
|
||||
});
|
||||
if (url.pathname === "/api/auth/login" || url.pathname === "/api/auth/refresh") {
|
||||
if (url.pathname === "/api/auth/refresh" && !signedIn) {
|
||||
return route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_001", message: "未登录" } }) });
|
||||
}
|
||||
if (url.pathname === "/api/auth/login") signedIn = true;
|
||||
return fulfillData(route, { accessToken: "cache-boundary-token", expiresIn: 900, mustChangePassword: false }, { "set-cookie": "has_session=1; Path=/; SameSite=Lax" });
|
||||
}
|
||||
if (url.pathname === "/api/me") return fulfillData(route, {
|
||||
|
||||
在新工单中引用
屏蔽一个用户