R7E 非人工剩余项处理收口

这个提交包含在:
2026-05-14 16:58:06 +08:00
父节点 b437743ef1
当前提交 45900e0673
共修改 5 个文件,包含 293 行新增和 14 行删除
@@ -11,7 +11,16 @@ import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "../helpers/workf
type StepStatus = "PASS" | "FAIL" | "SKIP";
type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动" | "企业微信UA";
type FlowKey = "preflight" | "reimbursement-finance" | "leave" | "purchase-attachment" | "mobile" | "wecom-ua" | "all";
type FlowKey =
| "preflight"
| "reimbursement-finance"
| "leave"
| "purchase-attachment"
| "mobile"
| "wecom-ua"
| "finance-closeout"
| "approval-boundary"
| "all";
type TrialStep = {
flow: FlowName;
@@ -44,6 +53,11 @@ type PaymentBatch = {
id: string;
name: string;
status: string;
items?: Array<{
entityId: string;
entityCode: string;
status: string;
}>;
reviewRequirement: {
completedReviewCount: number;
requiredReviewCount: number;
@@ -51,6 +65,22 @@ type PaymentBatch = {
};
};
type FinanceLedgerItem = {
id: string;
formNumber: string | null;
status: string;
payment: {
status: string;
paymentVoucherNo: string | null;
paidAt: string | null;
};
};
type FinanceLedgerResponse = {
total: number;
items: FinanceLedgerItem[];
};
type TrialState = {
reimbursementId?: string;
reimbursementCode?: string;
@@ -72,8 +102,20 @@ const reportDir = path.join(rootDir, "artifacts/r7e/browser-business-trialrun");
const reportPath = path.join(reportDir, "report.md");
const issueCandidatesPath = path.join(reportDir, "issues.csv");
const screenshotDir = path.join(rootDir, "output/playwright/r7e-browser-business-trialrun");
const validFlows = new Set<FlowKey>(["preflight", "reimbursement-finance", "leave", "purchase-attachment", "mobile", "wecom-ua", "all"]);
const validFlows = new Set<FlowKey>([
"preflight",
"reimbursement-finance",
"leave",
"purchase-attachment",
"mobile",
"wecom-ua",
"finance-closeout",
"approval-boundary",
"all",
]);
const loginRateLimitRetryMs = Number.parseInt(process.env.R7E_LOGIN_RETRY_WAIT_MS ?? "65000", 10);
const defaultCloseoutReimbursementId = "40876ef3-0652-4972-aa14-1aad1a855ae0";
const defaultCloseoutReimbursementCode = "BX-20260514-0016";
const wecomUa =
"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 Language/zh_CN";
@@ -140,6 +182,27 @@ function looksLikeLoginRateLimit(error: unknown) {
return /请求过于频繁|rate.?limit|too many requests|HTTP_429|429/i.test(message);
}
async function responseTextSummary(response: { status(): number; text(): Promise<string> }) {
const raw = await response.text().catch(() => "");
if (!raw) return `HTTP ${response.status()}`;
try {
const parsed = JSON.parse(raw) as { error?: { code?: string; message?: string; details?: unknown } };
const code = parsed.error?.code ?? `HTTP_${response.status()}`;
const message = parsed.error?.message ?? "";
const details = parsed.error?.details ? ` details=${JSON.stringify(parsed.error.details)}` : "";
return `${code}: ${message}${details}`.slice(0, 500);
} catch {
return `HTTP ${response.status()} ${raw.slice(0, 300)}`;
}
}
async function expectApiRejected(response: { ok(): boolean; status(): number; text(): Promise<string> }, label: string) {
if (response.ok()) {
throw new Error(`${label} 未被拒绝:HTTP ${response.status()}`);
}
return responseTextSummary(response);
}
async function waitForLoginRateLimitWindow() {
if (Number.isFinite(loginRateLimitRetryMs) && loginRateLimitRetryMs > 0) {
await new Promise((resolve) => setTimeout(resolve, loginRateLimitRetryMs));
@@ -191,6 +254,48 @@ async function firstEnabledExpenseTypeId(api: APIRequestContext) {
return item.id;
}
async function fetchFinanceLedger(api: APIRequestContext, query: { q?: string; page?: number; pageSize?: number } = {}) {
const params = new URLSearchParams();
params.set("page", String(query.page ?? 1));
params.set("pageSize", String(query.pageSize ?? 20));
if (query.q) params.set("q", query.q);
return apiGetData<FinanceLedgerResponse>(api, `/api/ap-operations/reimbursement-ledger?${params.toString()}`);
}
async function fetchPaymentBatches(api: APIRequestContext) {
return apiGetData<{ items: PaymentBatch[] }>(api, "/api/payment-batches");
}
async function findPaymentBatchByEntity(api: APIRequestContext, entityId: string) {
const batches = await fetchPaymentBatches(api);
return batches.items.find((batch) => batch.items?.some((item) => item.entityId === entityId)) ?? null;
}
async function createPaymentBatchViaApi(api: APIRequestContext, input: {
reimbursementId: string;
name: string;
marker: string;
}) {
return apiPostData<{ item: PaymentBatch }>(api, "/api/payment-batches", {
entityType: "reimbursement",
name: input.name,
entityIds: [input.reimbursementId],
defaultPaymentDate: new Date().toISOString().slice(0, 10),
defaultPaymentChannel: "bank",
voucherPrefix: `R7E-PAY-${input.marker.slice(-8)}`,
});
}
async function reviewPaymentBatchViaApi(api: APIRequestContext, batchId: string, marker: string) {
return apiPostData<{ item: PaymentBatch }>(api, `/api/payment-batches/${batchId}/review`, {
comment: `R7E 非人工付款复核 ${marker}`,
});
}
async function executePaymentBatchViaApi(api: APIRequestContext, batchId: string) {
return apiPostData<{ item: PaymentBatch }>(api, `/api/payment-batches/${batchId}/execute`, {});
}
async function createReimbursementItem(api: APIRequestContext, reimbursementId: string, marker: string) {
const expenseTypeId = await firstEnabledExpenseTypeId(api);
const response = await api.post(`/api/reimbursements/${reimbursementId}/items`, {
@@ -527,6 +632,172 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
return;
}
if (shouldRunFlow(selectedFlow, "finance-closeout")) {
await runStep("财务", "finance/financeMgr/admin", "AP API 诊断、付款闭环和管理员边界", async () => {
const reimbursementId = process.env.R7E_EXISTING_REIMBURSEMENT_ID?.trim() || defaultCloseoutReimbursementId;
const reimbursementCode = process.env.R7E_EXISTING_REIMBURSEMENT_CODE?.trim() || defaultCloseoutReimbursementCode;
const financeApi = await createLiveApiClientWithRetry("finance");
let financeMgrApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
let adminApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
try {
const reimbursementBefore = await apiGetData<ReimbursementDetail>(financeApi.api, `/api/reimbursements/${reimbursementId}`);
const ledgerResponse = await financeApi.api.get(`/api/ap-operations/reimbursement-ledger?q=${encodeURIComponent(reimbursementCode)}&page=1&pageSize=20`);
const paymentBatchesResponse = await financeApi.api.get("/api/payment-batches");
let paymentBatchesSummary = "";
if (paymentBatchesResponse.ok()) {
const paymentBatchesPayload = (await paymentBatchesResponse.json()) as { data?: { items?: PaymentBatch[] } };
const matchedBatch = paymentBatchesPayload.data?.items?.find((batch) => batch.items?.some((item) => item.entityId === reimbursementId));
paymentBatchesSummary = matchedBatch ? `matchedBatch=${matchedBatch.id}; status=${matchedBatch.status}` : "matchedBatch=none";
} else {
paymentBatchesSummary = `paymentBatches=${await responseTextSummary(paymentBatchesResponse)}`;
}
if (!ledgerResponse.ok()) {
return {
status: "SKIP",
businessId: `${reimbursementCode}; reimbursement=${reimbursementId}`,
pcResult: "SKIP: AP 台账 API 不可用",
conclusion: "暂停",
detail: `reimbursementStatus=${reimbursementBefore.status}; apLedger=${await responseTextSummary(ledgerResponse)}; ${paymentBatchesSummary}`,
};
}
const ledgerPayload = (await ledgerResponse.json()) as { data?: FinanceLedgerResponse };
const ledger = ledgerPayload.data;
const ledgerItem = ledger?.items?.find((item) => item.id === reimbursementId || item.formNumber === reimbursementCode) ?? null;
if (!ledgerItem) {
return {
status: "SKIP",
businessId: `${reimbursementCode}; reimbursement=${reimbursementId}`,
pcResult: "SKIP: 已审批报销未进入 AP 台账 API 结果",
conclusion: "暂停",
detail: `reimbursementStatus=${reimbursementBefore.status}; apLedgerTotal=${ledger?.total ?? "unknown"}; apLedgerMatched=false`,
};
}
state.reimbursementId = reimbursementId;
state.reimbursementCode = reimbursementCode;
let batch: PaymentBatch | null = null;
if (reimbursementBefore.status === "paid" || ledgerItem.payment.status === "paid") {
batch = await findPaymentBatchByEntity(financeApi.api, reimbursementId).catch(() => null);
} else {
if (!/approved/.test(reimbursementBefore.status)) {
return {
status: "SKIP",
businessId: `${reimbursementCode}; status=${reimbursementBefore.status}`,
pcResult: "SKIP: 报销未处于可付款状态",
conclusion: "暂停",
detail: `reimbursementStatus=${reimbursementBefore.status}; ledgerStatus=${ledgerItem.status}; paymentStatus=${ledgerItem.payment.status}`,
};
}
const existingBatch = await findPaymentBatchByEntity(financeApi.api, reimbursementId).catch(() => null);
if (existingBatch && ["draft", "reviewed", "failed"].includes(existingBatch.status)) {
batch = existingBatch;
} else {
const batchName = `R7E 非人工付款批次 ${marker}`;
const created = await createPaymentBatchViaApi(financeApi.api, { reimbursementId, name: batchName, marker });
batch = created.item;
}
if (batch.status === "draft") {
financeMgrApi = await createLiveApiClientWithRetry("financeMgr");
const reviewed = await reviewPaymentBatchViaApi(financeMgrApi.api, batch.id, marker);
batch = reviewed.item;
}
if (["reviewed", "failed"].includes(batch.status)) {
const executed = await executePaymentBatchViaApi(financeApi.api, batch.id);
batch = executed.item;
}
}
const reimbursementAfter = await apiGetData<ReimbursementDetail>(financeApi.api, `/api/reimbursements/${reimbursementId}`);
const finalLedger = await fetchFinanceLedger(financeApi.api, { q: reimbursementCode });
const finalLedgerItem = finalLedger.items.find((item) => item.id === reimbursementId || item.formNumber === reimbursementCode) ?? null;
if (reimbursementAfter.status !== "paid" || finalLedgerItem?.payment.status !== "paid") {
throw new Error(
`付款闭环后状态不一致:reimbursement=${reimbursementAfter.status}; ledgerPayment=${finalLedgerItem?.payment.status ?? "missing"}`,
);
}
adminApi = await createLiveApiClientWithRetry("admin");
const adminExecuteProbe = await adminApi.api.post("/api/payment-batches/not-a-valid-uuid/execute", { data: {} });
const adminBoundary = await expectApiRejected(adminExecuteProbe, "admin-only 执行付款探测");
if (adminExecuteProbe.status() !== 403) {
throw new Error(`admin-only 付款权限边界不是 403:${adminBoundary}`);
}
const finance = await loginRole(page, "finance");
try {
await page.goto("/ap-operations");
await expect(page.getByTestId("finance-ledger-table")).toBeVisible({ timeout: 30_000 });
await page.getByPlaceholder("单号、姓名、部门、项目").fill(reimbursementCode);
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementCode, { timeout: 30_000 });
await expect(page.getByTestId("finance-ledger-table")).toContainText(/已付款|paid/, { timeout: 30_000 });
} finally {
await logoutLiveUser(page, { name: finance.name }).catch(() => undefined);
}
return {
businessId: `${reimbursementCode}; reimbursement=${reimbursementId}; batch=${batch?.id ?? "already-paid"}`,
pcResult: "PASS: AP API 可见、付款批次复核执行、报销和台账已付款、管理员付款权限被拒绝",
detail: `reimbursementStatus=${reimbursementAfter.status}; ledgerPayment=${finalLedgerItem.payment.status}; batchStatus=${batch?.status ?? "already-paid"}; adminBoundary=${adminBoundary}`,
};
} finally {
await adminApi?.dispose();
await financeMgrApi?.dispose();
await financeApi.dispose();
}
});
}
if (shouldRunFlow(selectedFlow, "approval-boundary")) {
await runStep("审批", "employee/admin/manager", "真实待办申请人和 admin-only 越权拒绝", async () => {
const employeeApi = await createLiveApiClientWithRetry("employee");
let managerApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
let adminApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
const startDate = futureWeekday(uniqueFutureOffset(5200));
const employee = await loginRole(page, "employee");
try {
await (await openNavLink(page, "请假")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveId = await createLeaveDraft(page, startDate, `R7E 审批边界 ${marker}`);
const submitResult = await apiPostData<{ leaveRequestId: string; currentTaskId: string }>(employeeApi.api, `/api/leave-requests/${leaveId}/submit`, {});
const taskId = submitResult.currentTaskId;
if (!taskId) {
throw new Error(`请假提交后未返回 currentTaskId: ${JSON.stringify(submitResult)}`);
}
const selfApproveResponse = await employeeApi.api.post(`/api/workflow/tasks/${taskId}/approve`, { data: {} });
const selfBoundary = await expectApiRejected(selfApproveResponse, "申请人本人审批探测");
adminApi = await createLiveApiClientWithRetry("admin");
const adminApproveResponse = await adminApi.api.post(`/api/workflow/tasks/${taskId}/approve`, { data: {} });
const adminBoundary = await expectApiRejected(adminApproveResponse, "admin-only 审批探测");
managerApi = await createLiveApiClientWithRetry("manager");
await approveWorkflowTaskViaApi(managerApi.api, taskId);
await page.goto("/leave-requests");
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText(/通过|已通过/, { timeout: 30_000 });
return {
businessId: `leave=${leaveId}; task=${taskId}`,
pcResult: "PASS: 申请人本人和 admin-only 审批接口均被拒绝,经理正常通过并申请人回看已通过",
detail: `selfBoundary=${selfBoundary}; adminBoundary=${adminBoundary}`,
};
} finally {
await adminApi?.dispose();
await managerApi?.dispose();
await employeeApi.dispose();
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
}
});
}
if (shouldRunFlow(selectedFlow, "reimbursement-finance")) {
const existingReimbursementId = process.env.R7E_EXISTING_REIMBURSEMENT_ID?.trim() || "";
if (existingReimbursementId) {