R7E 非人工剩余项处理收口
这个提交包含在:
@@ -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) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户