完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -0,0 +1,430 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
type SyntheticActor = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roleCodes: string[];
|
||||
};
|
||||
|
||||
type SyntheticContract = {
|
||||
id: string;
|
||||
contractNo: string;
|
||||
projectId: string | null;
|
||||
contractType: "purchase" | "sales" | "service" | "framework" | "other";
|
||||
counterpartyName: string;
|
||||
summary: string;
|
||||
amount: string | null;
|
||||
currency: string;
|
||||
signedDate: string | null;
|
||||
effectiveDate: string | null;
|
||||
expiryDate: string | null;
|
||||
confidentialityLevel: "public" | "internal" | "confidential" | "restricted";
|
||||
archiveLocation: string;
|
||||
offlineArchiveNo: string | null;
|
||||
ownerEmployeeId: string;
|
||||
status: "draft" | "active" | "expired" | "archived";
|
||||
persistedStatus: "draft" | "active" | "archived";
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
owner: { id: string; employeeNo: string; name: string } | null;
|
||||
project: { id: string; projectNo: string; name: string } | null;
|
||||
};
|
||||
|
||||
type MockScenario = {
|
||||
paymentCandidates?: Array<{
|
||||
entityId: string;
|
||||
entityType: "reimbursement";
|
||||
businessNo: string;
|
||||
employee: { id: string; employeeNo: string; name: string };
|
||||
amount: string;
|
||||
currencyCode: "CNY";
|
||||
businessDate: string | null;
|
||||
summary: string | null;
|
||||
}>;
|
||||
contracts?: SyntheticContract[];
|
||||
onCreatePaymentBatch?: (payload: unknown) => void;
|
||||
};
|
||||
|
||||
const REVIEWER_ACTOR: SyntheticActor = {
|
||||
id: "employee-payment-reviewer",
|
||||
employeeNo: "SYN-REVIEWER",
|
||||
name: "合成付款复核人",
|
||||
email: "[email protected]",
|
||||
roleCodes: ["manager"],
|
||||
};
|
||||
|
||||
const FINANCE_ACTOR: SyntheticActor = {
|
||||
id: "employee-payment-finance",
|
||||
employeeNo: "SYN-FINANCE",
|
||||
name: "合成财务人员",
|
||||
email: "[email protected]",
|
||||
roleCodes: ["finance"],
|
||||
};
|
||||
|
||||
const CONTRACT_READER_ACTOR: SyntheticActor = {
|
||||
id: "employee-contract-reader",
|
||||
employeeNo: "SYN-CONTRACT-READER",
|
||||
name: "合成合同只读人员",
|
||||
email: "[email protected]",
|
||||
roleCodes: ["employee"],
|
||||
};
|
||||
|
||||
const NO_INVOICE_UPLOAD_ACTOR: SyntheticActor = {
|
||||
id: "employee-no-invoice-upload",
|
||||
employeeNo: "SYN-NO-INVOICE-UPLOAD",
|
||||
name: "合成无票据上传人员",
|
||||
email: "[email protected]",
|
||||
roleCodes: ["hr"],
|
||||
};
|
||||
|
||||
const PAYMENT_CANDIDATE_ENTITY_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const CREATED_PAYMENT_BATCH_ID = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const PAYMENT_CANDIDATE = {
|
||||
entityId: PAYMENT_CANDIDATE_ENTITY_ID,
|
||||
entityType: "reimbursement" as const,
|
||||
businessNo: "BX-SYN-20260718-01",
|
||||
employee: {
|
||||
id: "employee-payment-candidate",
|
||||
employeeNo: "SYN-PAYEE",
|
||||
name: "合成付款员工",
|
||||
},
|
||||
amount: "1280.50",
|
||||
currencyCode: "CNY" as const,
|
||||
businessDate: "2026-07-18",
|
||||
summary: "合成已审批报销",
|
||||
};
|
||||
|
||||
const CONTRACT_ITEM: SyntheticContract = {
|
||||
id: "contract-synthetic-readonly",
|
||||
contractNo: "HT-SYN-READ-001",
|
||||
projectId: "project-synthetic-contract",
|
||||
contractType: "service",
|
||||
counterpartyName: "合成合作方",
|
||||
summary: "合成只读合同",
|
||||
amount: "8000.00",
|
||||
currency: "CNY",
|
||||
signedDate: "2026-07-01",
|
||||
effectiveDate: "2026-07-01",
|
||||
expiryDate: "2027-06-30",
|
||||
confidentialityLevel: "internal",
|
||||
archiveLocation: "受控线下归档",
|
||||
offlineArchiveNo: "SYN-ARCHIVE-001",
|
||||
ownerEmployeeId: CONTRACT_READER_ACTOR.id,
|
||||
status: "active",
|
||||
persistedStatus: "active",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
owner: {
|
||||
id: CONTRACT_READER_ACTOR.id,
|
||||
employeeNo: CONTRACT_READER_ACTOR.employeeNo,
|
||||
name: CONTRACT_READER_ACTOR.name,
|
||||
},
|
||||
project: {
|
||||
id: "project-synthetic-contract",
|
||||
projectNo: "XM-SYN-001",
|
||||
name: "合成合同项目",
|
||||
},
|
||||
};
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-payment-reviewer-permissions", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function capturePageErrors(page: Page) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
function expectNoPageErrors(errors: string[]) {
|
||||
expect(errors, "页面不应产生未捕获异常").toEqual([]);
|
||||
}
|
||||
|
||||
async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) {
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const pathName = new URL(request.url()).pathname;
|
||||
|
||||
if (pathName === "/api/auth/capabilities") {
|
||||
await fulfillJson(route, {
|
||||
emailDelivery: { ready: false },
|
||||
emailLoginCode: { enabled: false },
|
||||
passwordReset: { enabled: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: false,
|
||||
entryEnabled: false,
|
||||
oauthEnabled: false,
|
||||
corpId: "",
|
||||
agentId: "",
|
||||
loginUrl: null,
|
||||
bindEnabled: false,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "disabled",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/auth/login" || pathName === "/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-token-${actor.employeeNo}`,
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/me") {
|
||||
await fulfillJson(route, {
|
||||
...actor,
|
||||
phone: null,
|
||||
departmentId: `department-${actor.employeeNo}`,
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: false,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 40,
|
||||
},
|
||||
wecomBinding: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: `workspace-${actor.employeeNo}`,
|
||||
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: [], admin: [], backup: [] },
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/notifications") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
unreadOnly: false,
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: actor.name },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/payment-batches/candidates") {
|
||||
const items = scenario.paymentCandidates ?? [];
|
||||
await fulfillJson(route, { items, page: 1, pageSize: 50, total: items.length });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/payment-batches" && request.method() === "POST") {
|
||||
scenario.onCreatePaymentBatch?.(request.postDataJSON());
|
||||
await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/payment-batches") {
|
||||
await fulfillJson(route, { items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/risk-flags") {
|
||||
await fulfillJson(route, { items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/ap-operations") {
|
||||
await fulfillJson(route, {
|
||||
summary: {
|
||||
pendingPaymentCount: 0,
|
||||
partialMatchCount: 0,
|
||||
exceptionCount: 0,
|
||||
budgetOverspendCount: 0,
|
||||
},
|
||||
pendingPaymentBatches: [],
|
||||
failedPaymentBatchItems: [],
|
||||
exceptionRiskFlags: [],
|
||||
budgetOverspendRisks: [],
|
||||
partialMatchItems: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/ap-operations/reimbursement-ledger") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/contracts-registry/expiring") {
|
||||
await fulfillJson(route, { days: 30, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/contracts-registry") {
|
||||
await fulfillJson(route, { items: scenario.contracts ?? [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/projects") {
|
||||
await fulfillJson(route, { items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { message: "unhandled synthetic endpoint" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
async function login(page: Page, actor: SyntheticActor) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(actor.email);
|
||||
await page.getByTestId("login-password").fill("SyntheticPassword1234");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/(?:portal|mobile)$/);
|
||||
}
|
||||
|
||||
test("只有付款复核职责的经理可看应付账款但看不到导出", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
await mockSyntheticApis(page, REVIEWER_ACTOR);
|
||||
await login(page, REVIEWER_ACTOR);
|
||||
|
||||
await page.goto("/ap-operations");
|
||||
await expect(page).toHaveURL(/\/ap-operations$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("ap-export")).toHaveCount(0);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("只有付款复核职责的经理看不到新建批次", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
await mockSyntheticApis(page, REVIEWER_ACTOR);
|
||||
await login(page, REVIEWER_ACTOR);
|
||||
|
||||
await page.goto("/payment-batches");
|
||||
await expect(page).toHaveURL(/\/payment-batches$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "新建批次", exact: true })).toHaveCount(0);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
|
||||
await login(page, CONTRACT_READER_ACTOR);
|
||||
|
||||
await page.goto("/contracts-registry");
|
||||
await expect(page).toHaveURL(/\/contracts-registry$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
|
||||
|
||||
const contractRow = page
|
||||
.getByTestId("contracts-registry-table")
|
||||
.getByRole("row")
|
||||
.filter({ hasText: CONTRACT_ITEM.contractNo });
|
||||
await expect(contractRow).toBeVisible();
|
||||
await contractRow.getByRole("button", { name: "更多", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/contracts-registry$/);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
await mockSyntheticApis(page, NO_INVOICE_UPLOAD_ACTOR);
|
||||
await login(page, NO_INVOICE_UPLOAD_ACTOR);
|
||||
|
||||
await page.goto("/invoice-pool");
|
||||
await expect(page).toHaveURL(/\/403$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "无权限访问", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "上传票据", exact: true })).toHaveCount(0);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
let createPayload: unknown = null;
|
||||
await mockSyntheticApis(page, FINANCE_ACTOR, {
|
||||
paymentCandidates: [PAYMENT_CANDIDATE],
|
||||
onCreatePaymentBatch: (payload) => {
|
||||
createPayload = payload;
|
||||
},
|
||||
});
|
||||
await login(page, FINANCE_ACTOR);
|
||||
|
||||
await page.goto("/payment-batches");
|
||||
await expect(page).toHaveURL(/\/payment-batches$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "新建批次", exact: true }).click();
|
||||
|
||||
const candidateRow = page.getByTestId("payment-batch-candidate-row");
|
||||
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo);
|
||||
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name);
|
||||
await expect(candidateRow).toContainText(`${PAYMENT_CANDIDATE.amount} CNY`);
|
||||
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("payment-batch-create-name").fill("合成付款批次");
|
||||
await candidateRow.getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: "创建批次", exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
|
||||
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("付款批次已创建", { exact: true })).toBeVisible();
|
||||
expect(createPayload).toEqual({
|
||||
entityType: "reimbursement",
|
||||
name: "合成付款批次",
|
||||
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
|
||||
});
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户