文件
hyb-oa/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts
T

523 行
19 KiB
TypeScript

import { expect, test, type APIRequestContext } from "@playwright/test";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { apiGetData, apiPostData, type AuthenticatedApiClient } from "../helpers/auth";
import { createLiveApiClient, type LiveSmokeRole } from "../helpers/liveAuth";
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "../helpers/workflow";
type FeatureFlagItem = {
flagKey: string;
overrideMode: "inherit" | "force_on" | "force_off";
rolloutType: "global" | "department_allowlist";
departmentIds: string[];
note: string;
version: number;
effectiveEnabled: boolean;
effectiveReason: string;
};
type FeatureFlagSnapshot = Pick<
FeatureFlagItem,
"flagKey" | "overrideMode" | "rolloutType" | "departmentIds" | "version" | "effectiveEnabled" | "effectiveReason"
>;
type ReimbursementDetail = {
id: string;
formNumber: string | null;
status: string;
totalAmount: string | null;
};
type InvoiceDetail = {
id: string;
status: string;
linkedReimbursementId: string | null;
archivedAt: string | null;
};
type PaymentBatch = {
id: string;
name: string;
status: string;
reviewRequirement: {
completedReviewCount: number;
requiredReviewCount: number;
nextStep: string;
};
};
type FinanceLedgerResponse = {
total: number;
items: Array<{
id: string;
formNumber: string | null;
status: string;
payment: {
status: string;
paymentVoucherNo: string | null;
paidAt: string | null;
};
}>;
};
type AuditLogListResponse = {
items: Array<{
action: string;
targetId: string | null;
actorId: string | null;
extra: Record<string, unknown> | null;
}>;
};
type LiveSummary = {
issueId: string;
mode: string;
status: "RUNNING" | "PASS" | "FAIL";
baseUrl: string;
marker: string;
startedAt: string;
completedAt?: string;
stages: Record<string, unknown>;
error?: string;
};
const issueId = "P2FLIVE";
const baseUrl = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com";
const reportDir = process.env.P2_FIN_LIVE_REPORT_DIR?.trim() ||
path.resolve(process.cwd(), "..", "artifacts/qa-flow/p2-fin-live-local");
const summaryPath = path.join(reportDir, "live-result.json");
function nowMarker() {
return `P2FLIVE-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function sanitizeError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer <redacted>")
.replace(/password[^,}\n]*/gi, "password=<redacted>")
.slice(0, 1000);
}
function snapshotFlag(item: FeatureFlagItem): FeatureFlagSnapshot {
return {
flagKey: item.flagKey,
overrideMode: item.overrideMode,
rolloutType: item.rolloutType,
departmentIds: item.departmentIds,
version: item.version,
effectiveEnabled: item.effectiveEnabled,
effectiveReason: item.effectiveReason,
};
}
async function parseData<T>(response: { ok(): boolean; status(): number; text(): Promise<string> }, label: string) {
const text = await response.text();
let payload: { data?: T; error?: { code?: string; message?: string; details?: unknown } } = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = {};
}
if (!response.ok()) {
const error = payload.error;
throw new Error(`${label} failed: HTTP ${response.status()} ${error?.code ?? ""} ${error?.message ?? ""}`.trim());
}
return payload.data as T;
}
async function createLiveClientWithRetry(role: LiveSmokeRole): Promise<AuthenticatedApiClient> {
let lastError: unknown = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
return await createLiveApiClient(role);
} catch (error) {
lastError = error;
if (!/429|请求过于频繁|rate.?limit|too many requests/i.test(sanitizeError(error)) || attempt > 0) {
break;
}
await sleep(Number.parseInt(process.env.P2_FIN_LIVE_LOGIN_RETRY_WAIT_MS ?? "65000", 10));
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
async function loadFlag(api: APIRequestContext, flagKey: string) {
const data = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
const item = data.items.find((candidate) => candidate.flagKey === flagKey);
if (!item) {
throw new Error(`Missing feature flag: ${flagKey}`);
}
return item;
}
async function updateFlag(
api: APIRequestContext,
flagKey: string,
overrideMode: FeatureFlagItem["overrideMode"],
note: string,
) {
const current = await loadFlag(api, flagKey);
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
data: {
overrideMode,
rolloutType: "global",
departmentIds: [],
note,
expectedVersion: current.version,
},
});
const data = await parseData<{ item: FeatureFlagItem }>(response, `update ${flagKey}`);
return data.item;
}
async function forcePaymentBatchOff(api: APIRequestContext, note: string) {
const current = await loadFlag(api, "payment_batch_v1");
if (current.overrideMode === "force_off" && !current.effectiveEnabled) {
return current;
}
return updateFlag(api, "payment_batch_v1", "force_off", note);
}
function resolveLivePdfBuffer(marker: string) {
const sample = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() || process.env.LIVE_OCR_INVOICE_FILE?.trim() || "";
if (!sample || !existsSync(sample) || path.extname(sample).toLowerCase() !== ".pdf") {
throw new Error("missing_authorized_pdf_sample");
}
const source = readFileSync(sample);
return Buffer.concat([source, Buffer.from(`\n% ${marker}\n`, "utf8")]);
}
async function firstEnabledExpenseTypeId(api: APIRequestContext) {
const data = await apiGetData<{ items: Array<{ id: string; enabled?: boolean }> }>(api, "/api/expense-types?enabled=true");
const item = data.items.find((candidate) => candidate.enabled !== false) ?? data.items[0];
if (!item?.id) {
throw new Error("missing_enabled_expense_type");
}
return item.id;
}
async function createTestReimbursement(api: APIRequestContext, marker: string) {
const reimbursement = await apiPostData<ReimbursementDetail>(api, "/api/reimbursements", {
description: `P2FINLIVE 公网付款测试 ${marker}`,
reimbursementDate: today(),
});
const expenseTypeId = await firstEnabledExpenseTypeId(api);
await apiPostData<Record<string, unknown>>(api, `/api/reimbursements/${reimbursement.id}/items`, {
expenseTypeId,
expenseDate: today(),
amount: "168.30",
description: `P2FINLIVE 小额测试 ${marker}`,
});
return apiGetData<ReimbursementDetail>(api, `/api/reimbursements/${reimbursement.id}`);
}
async function uploadAndBindPdfInvoice(api: APIRequestContext, reimbursementId: string, marker: string) {
const buffer = resolveLivePdfBuffer(marker);
const response = await api.post("/api/invoice-pool/upload", {
multipart: {
ocr_type: "general",
file: {
name: `p2-fin-live-${marker}.pdf`,
mimeType: "application/pdf",
buffer,
},
},
});
const upload = await parseData<{
invoicePoolId: string | null;
status: string;
uploadAction?: string;
unavailableReason?: string | null;
}>(response, "upload invoice");
if (!upload.invoicePoolId || upload.status !== "available") {
throw new Error(`invoice_not_bindable:${upload.status}:${upload.uploadAction ?? "-"}:${upload.unavailableReason ?? "-"}`);
}
await apiPostData<Record<string, unknown>>(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, { reimbursementId });
return upload.invoicePoolId;
}
async function approveReimbursementToApproved(input: {
employeeApi: APIRequestContext;
managerApi: APIRequestContext;
financeApi: APIRequestContext;
reimbursementId: string;
}) {
const submit = await apiPostData<{ currentTaskId?: string }>(
input.employeeApi,
`/api/reimbursements/${input.reimbursementId}/submit`,
{},
);
const managerTaskId =
submit.currentTaskId ??
(await waitForWorkflowTask(input.managerApi, { entityType: "reimbursement", entityId: input.reimbursementId })).id;
await approveWorkflowTaskViaApi(input.managerApi, managerTaskId);
let financeTaskId = "";
try {
financeTaskId = (await waitForWorkflowTask(input.financeApi, {
entityType: "reimbursement",
entityId: input.reimbursementId,
attempts: 12,
intervalMs: 1000,
})).id;
} catch {
financeTaskId = "";
}
if (financeTaskId) {
await approveWorkflowTaskViaApi(input.financeApi, financeTaskId);
}
await expect
.poll(async () => {
const detail = await apiGetData<ReimbursementDetail>(input.financeApi, `/api/reimbursements/${input.reimbursementId}`);
return detail.status;
}, { timeout: 30_000 })
.toBe("approved");
return { managerTaskId, financeTaskId };
}
async function createPaymentBatch(api: APIRequestContext, input: { reimbursementId: string; name: string; marker: string }) {
const data = await apiPostData<{ item: PaymentBatch }>(api, "/api/payment-batches", {
entityType: "reimbursement",
name: input.name,
entityIds: [input.reimbursementId],
defaultPaymentDate: today(),
defaultPaymentChannel: "bank",
voucherPrefix: `P2LIVE-${input.marker.slice(-6)}`,
});
return data.item;
}
async function expectPostRejected(api: APIRequestContext, pathName: string, data: unknown, label: string) {
const response = await api.post(pathName, { data });
expect(response.ok(), `${label} should be rejected`).toBe(false);
expect([403, 409, 404]).toContain(response.status());
return response.status();
}
function isWildcardSuperAdmin(client: AuthenticatedApiClient) {
const roleCodes = client.me.roleCodes ?? [];
return roleCodes.includes("admin");
}
async function fetchFinanceLedger(api: APIRequestContext, query: string) {
const params = new URLSearchParams({ page: "1", pageSize: "20", q: query });
return apiGetData<FinanceLedgerResponse>(api, `/api/ap-operations/reimbursement-ledger?${params.toString()}`);
}
async function expectAuditAction(
api: APIRequestContext,
action: string,
predicate: (item: AuditLogListResponse["items"][number]) => boolean,
) {
await expect
.poll(async () => {
const data = await apiGetData<AuditLogListResponse>(
api,
`/api/audit-logs?action=${encodeURIComponent(action)}&page=1&pageSize=50`,
);
return data.items.some(predicate);
}, { timeout: 20_000 })
.toBe(true);
}
test.describe("P2-FIN-LIVE 公网付款批次短窗口", () => {
test.skip(process.env.E2E_MODE !== "live", "仅在 live 模式下执行");
test.describe.configure({ mode: "serial" });
test("公网付款批次 flag-on 闭环后强制回滚到 flag-off", async ({}, testInfo) => {
test.setTimeout(420_000);
if (process.env.P2_FIN_LIVE_APPROVED !== "1") {
throw new Error("P2_FIN_LIVE_APPROVED=1 is required for public write test");
}
mkdirSync(reportDir, { recursive: true });
const marker = nowMarker();
const summary: LiveSummary = {
issueId,
mode: "p2-fin-live",
status: "RUNNING",
baseUrl,
marker,
startedAt: new Date().toISOString(),
stages: {},
};
const clients: AuthenticatedApiClient[] = [];
let admin: AuthenticatedApiClient | null = null;
let caughtError: unknown = null;
let rollbackError: unknown = null;
try {
admin = await createLiveClientWithRetry("admin");
const employee = await createLiveClientWithRetry("employee");
const manager = await createLiveClientWithRetry("manager");
const finance = await createLiveClientWithRetry("finance");
const financeMgr = await createLiveClientWithRetry("financeMgr");
clients.push(admin, employee, manager, finance, financeMgr);
const paymentFlagBefore = await loadFlag(admin.api, "payment_batch_v1");
const erpFlagBefore = await loadFlag(admin.api, "erp_sync");
summary.stages.preflight = {
paymentFlagBefore: snapshotFlag(paymentFlagBefore),
erpSyncEffectiveEnabled: erpFlagBefore.effectiveEnabled,
};
expect(paymentFlagBefore.effectiveEnabled, "payment_batch_v1 should be off before public live window").toBe(false);
expect(erpFlagBefore.effectiveEnabled, "erp_sync must remain off for this test").toBe(false);
const reimbursement = await createTestReimbursement(employee.api, marker);
const invoiceId = await uploadAndBindPdfInvoice(employee.api, reimbursement.id, marker);
const approval = await approveReimbursementToApproved({
employeeApi: employee.api,
managerApi: manager.api,
financeApi: finance.api,
reimbursementId: reimbursement.id,
});
const approved = await apiGetData<ReimbursementDetail>(finance.api, `/api/reimbursements/${reimbursement.id}`);
summary.stages.reimbursement = {
reimbursementId: reimbursement.id,
formNumber: approved.formNumber,
status: approved.status,
invoiceId,
managerTaskId: approval.managerTaskId,
financeTaskApproved: Boolean(approval.financeTaskId),
};
const paymentFlagOn = await updateFlag(
admin.api,
"payment_batch_v1",
"force_on",
`P2FLIVE short window ${marker}`,
);
summary.stages.flagOn = { paymentFlagOn: snapshotFlag(paymentFlagOn) };
expect(paymentFlagOn.effectiveEnabled).toBe(true);
const erpFlagAfterPaymentOn = await loadFlag(admin.api, "erp_sync");
expect(erpFlagAfterPaymentOn.effectiveEnabled, "erp_sync must not be opened by P2FLIVE").toBe(false);
const batchName = `P2FINLIVE 公网付款批次 ${marker}`;
const adminWildcard = isWildcardSuperAdmin(admin);
const rejectedCreate: Record<string, string | number> = {
employee: await expectPostRejected(employee.api, "/api/payment-batches", {
entityType: "reimbursement",
name: `${batchName} employee denied`,
entityIds: [reimbursement.id],
}, "employee create payment batch"),
manager: await expectPostRejected(manager.api, "/api/payment-batches", {
entityType: "reimbursement",
name: `${batchName} manager denied`,
entityIds: [reimbursement.id],
}, "manager create payment batch"),
};
if (adminWildcard) {
rejectedCreate.admin = "SKIP_WILDCARD_SUPERADMIN";
} else {
rejectedCreate.admin = await expectPostRejected(admin.api, "/api/payment-batches", {
entityType: "reimbursement",
name: `${batchName} admin denied`,
entityIds: [reimbursement.id],
}, "admin-only create payment batch");
}
let batch = await createPaymentBatch(finance.api, {
reimbursementId: reimbursement.id,
name: batchName,
marker,
});
const adminExecuteBoundary = adminWildcard
? "SKIP_WILDCARD_SUPERADMIN"
: await expectPostRejected(admin.api, `/api/payment-batches/${batch.id}/execute`, {}, "admin-only execute payment batch");
batch = (await apiPostData<{ item: PaymentBatch }>(financeMgr.api, `/api/payment-batches/${batch.id}/review`, {
comment: `P2FLIVE 复核 ${marker}`,
})).item;
expect(batch.reviewRequirement.completedReviewCount).toBeGreaterThanOrEqual(1);
batch = (await apiPostData<{ item: PaymentBatch }>(finance.api, `/api/payment-batches/${batch.id}/execute`, {})).item;
expect(batch.status).toBe("executed");
const paid = await apiGetData<ReimbursementDetail>(finance.api, `/api/reimbursements/${reimbursement.id}`);
expect(paid.status).toBe("paid");
const invoice = await apiGetData<InvoiceDetail>(employee.api, `/api/invoice-pool/${invoiceId}`);
expect(invoice.status).toBe("archived");
expect(invoice.linkedReimbursementId).toBe(reimbursement.id);
const ledger = await fetchFinanceLedger(finance.api, paid.formNumber ?? reimbursement.id);
const ledgerItem = ledger.items.find((item) => item.id === reimbursement.id || item.formNumber === paid.formNumber);
expect(ledgerItem?.payment.status).toBe("paid");
const exportResponse = await finance.api.post("/api/exports/ap-operations", {
data: { format: "csv", ledgerSearch: paid.formNumber ?? reimbursement.id },
});
expect(exportResponse.status(), "finance AP export should succeed while flag is on").toBe(200);
await expectAuditAction(admin.api, "FEATURE_FLAG_UPDATED", (item) =>
item.extra?.flagKey === "payment_batch_v1" &&
(item.extra?.to as Record<string, unknown> | undefined)?.overrideMode === "force_on",
);
await expectAuditAction(admin.api, "PAYMENT_BATCH_CREATED", (item) => item.targetId === batch.id);
await expectAuditAction(admin.api, "PAYMENT_BATCH_REVIEWED", (item) => item.targetId === batch.id);
await expectAuditAction(admin.api, "PAYMENT_BATCH_EXECUTED", (item) => item.targetId === batch.id);
await expectAuditAction(admin.api, "REIMBURSEMENT_PAYMENT_MARKED", (item) => item.targetId === reimbursement.id);
await expectAuditAction(admin.api, "EXPORT_AP_OPERATIONS", (item) => item.actorId === finance.me.id);
summary.stages.payment = {
batchId: batch.id,
batchStatus: batch.status,
reimbursementStatus: paid.status,
invoiceStatus: invoice.status,
ledgerPaymentStatus: ledgerItem?.payment.status ?? "missing",
rejectedCreate,
adminExecuteBoundary,
exportStatus: exportResponse.status(),
};
summary.status = "PASS";
} catch (error) {
caughtError = error;
summary.status = "FAIL";
summary.error = sanitizeError(error);
} finally {
if (admin) {
try {
const rolledBack = await forcePaymentBatchOff(admin.api, `P2FLIVE rollback ${marker}`);
summary.stages.rollback = { paymentFlagAfterRollback: snapshotFlag(rolledBack) };
expect(rolledBack.effectiveEnabled).toBe(false);
} catch (error) {
rollbackError = error;
summary.status = "FAIL";
summary.stages.rollback = { error: sanitizeError(error) };
}
}
summary.completedAt = new Date().toISOString();
writeFileSync(summaryPath, JSON.stringify(summary, null, 2), "utf8");
await testInfo.attach("p2-fin-live-summary.json", {
path: summaryPath,
contentType: "application/json",
});
await Promise.all(clients.map((client) => client.dispose().catch(() => undefined)));
}
if (caughtError) {
throw caughtError;
}
if (rollbackError) {
throw rollbackError;
}
});
});