fix(detail-page-structure-progress-actions): 收敛操作面板定位并补充制单诊断
这个提交包含在:
+1
-1
@@ -666,7 +666,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-action-panel {
|
.workflow-action-panel {
|
||||||
@apply relative z-30 lg:sticky lg:top-20;
|
@apply relative z-30;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-action-bar {
|
.workflow-action-bar {
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export function WorkflowTaskDetailPage() {
|
|||||||
<BusinessDetailAside>
|
<BusinessDetailAside>
|
||||||
<BusinessDetailActionPanel
|
<BusinessDetailActionPanel
|
||||||
title="处理"
|
title="处理"
|
||||||
className="workflow-action-panel lg:top-20"
|
className="workflow-action-panel"
|
||||||
secondaryActions={(
|
secondaryActions={(
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1159,6 +1159,8 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
|||||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
|
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
|
||||||
await expect(page.locator(".workflow-action-panel")).toHaveCSS("position", "sticky");
|
await expect(page.locator(".workflow-action-panel")).toHaveCSS("position", "sticky");
|
||||||
|
await expect(page.locator(".workflow-action-panel")).toHaveCSS("top", "96px");
|
||||||
|
await expect(page.locator(".workflow-action-panel")).toHaveCSS("z-index", "30");
|
||||||
await page.setViewportSize({ width: 1440, height: 960 });
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
|
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
|
||||||
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
|
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
|
||||||
|
|||||||
@@ -1675,6 +1675,12 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
const resultCode = error instanceof PaymentOrderXlsError
|
const resultCode = error instanceof PaymentOrderXlsError
|
||||||
? error.code
|
? error.code
|
||||||
: error?.details?.status ?? error?.code ?? "export_failed";
|
: error?.details?.status ?? error?.code ?? "export_failed";
|
||||||
|
if (error instanceof PaymentOrderXlsError && error.diagnostic) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ diagnostic: error.diagnostic, requestId: request.id },
|
||||||
|
"payment order generator unavailable",
|
||||||
|
);
|
||||||
|
}
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
|
|||||||
@@ -4,14 +4,23 @@ const { spawnSync } = require("node:child_process");
|
|||||||
const { decryptStringOrThrow } = require("./crypto");
|
const { decryptStringOrThrow } = require("./crypto");
|
||||||
|
|
||||||
class PaymentOrderXlsError extends Error {
|
class PaymentOrderXlsError extends Error {
|
||||||
constructor(code, details = {}) {
|
constructor(code, details = {}, diagnostic = null) {
|
||||||
super(code);
|
super(code);
|
||||||
this.name = "PaymentOrderXlsError";
|
this.name = "PaymentOrderXlsError";
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
|
if (diagnostic) {
|
||||||
|
Object.defineProperty(this, "diagnostic", {
|
||||||
|
value: diagnostic,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAYMENT_ORDER_RUNTIME_DIAGNOSTIC =
|
||||||
|
"BIFF8 运行时不可用:请安装 server/requirements/payment-order-biff8.txt 中的固定依赖,并通过 PAYMENT_ORDER_PYTHON_BIN 指定 Python 可执行文件。";
|
||||||
|
|
||||||
function buildCustomerSerial(batchNo, rowNumber) {
|
function buildCustomerSerial(batchNo, rowNumber) {
|
||||||
const serial = `${String(batchNo).trim()}-R${String(rowNumber).padStart(3, "0")}`;
|
const serial = `${String(batchNo).trim()}-R${String(rowNumber).padStart(3, "0")}`;
|
||||||
if (serial.length > 32) {
|
if (serial.length > 32) {
|
||||||
@@ -177,10 +186,13 @@ function mapTemplateProcessError(result) {
|
|||||||
const matched = stderr.match(/^\[payment-order-template\]\[ERROR\] ([a-z_]+)\s*$/);
|
const matched = stderr.match(/^\[payment-order-template\]\[ERROR\] ([a-z_]+)\s*$/);
|
||||||
const internalCode = matched && PYTHON_ERROR_CODES.has(matched[1]) ? matched[1] : "process_failed";
|
const internalCode = matched && PYTHON_ERROR_CODES.has(matched[1]) ? matched[1] : "process_failed";
|
||||||
if (internalCode === "runtime_dependency_unavailable" || result?.error?.code === "ENOENT") {
|
if (internalCode === "runtime_dependency_unavailable" || result?.error?.code === "ENOENT") {
|
||||||
return "payment_order_generator_unavailable";
|
return {
|
||||||
|
code: "payment_order_generator_unavailable",
|
||||||
|
diagnostic: PAYMENT_ORDER_RUNTIME_DIAGNOSTIC,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (internalCode.startsWith("template_")) return "payment_order_template_unavailable";
|
if (internalCode.startsWith("template_")) return { code: "payment_order_template_unavailable" };
|
||||||
return "payment_order_generation_failed";
|
return { code: "payment_order_generation_failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePaymentOrderXls(config, batch, options = {}) {
|
function generatePaymentOrderXls(config, batch, options = {}) {
|
||||||
@@ -201,7 +213,8 @@ function generatePaymentOrderXls(config, batch, options = {}) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.status !== 0 || result.error || !Buffer.isBuffer(result.stdout)) {
|
if (result.status !== 0 || result.error || !Buffer.isBuffer(result.stdout)) {
|
||||||
throw new PaymentOrderXlsError(mapTemplateProcessError(result));
|
const mappedError = mapTemplateProcessError(result);
|
||||||
|
throw new PaymentOrderXlsError(mappedError.code, {}, mappedError.diagnostic);
|
||||||
}
|
}
|
||||||
const buffer = result.stdout;
|
const buffer = result.stdout;
|
||||||
if (buffer.length > maxBuffer || buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1") {
|
if (buffer.length > maxBuffer || buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1") {
|
||||||
|
|||||||
@@ -2415,6 +2415,81 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs BIFF8 runtime guidance without exposing it in the finance API response", async () => {
|
||||||
|
const payerEncrypted = encryptStringOrThrow(
|
||||||
|
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||||
|
`6217${"0".repeat(8)}1234`,
|
||||||
|
);
|
||||||
|
const payeeEncrypted = encryptStringOrThrow(
|
||||||
|
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||||
|
SYNTHETIC_BANK_ACCOUNT,
|
||||||
|
);
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
department_id: "dept-1",
|
||||||
|
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||||
|
roleCodes: ["finance"],
|
||||||
|
},
|
||||||
|
batch: {
|
||||||
|
status: "reviewed",
|
||||||
|
payment_account_snapshot_json: {
|
||||||
|
accountName: "合成测试公司",
|
||||||
|
bankName: "中国建设银行",
|
||||||
|
bankInstitutionCode: "CCB",
|
||||||
|
accountNumberMasked: "6217********1234",
|
||||||
|
accountNumberEncrypted: payerEncrypted,
|
||||||
|
},
|
||||||
|
items: buildPaymentBatch().items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
snapshot_json: {
|
||||||
|
payee: {
|
||||||
|
name: "测试员工",
|
||||||
|
bankName: "中国建设银行",
|
||||||
|
bankInstitutionCode: "CCB",
|
||||||
|
bankAccountMasked: "6222********5678",
|
||||||
|
bankAccountEncrypted: payeeEncrypted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const logError = vi.spyOn(app.log, "error");
|
||||||
|
const originalPythonBin = process.env.PAYMENT_ORDER_PYTHON_BIN;
|
||||||
|
process.env.PAYMENT_ORDER_PYTHON_BIN = "/definitely-missing/payment-order-python";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { reason: "运行时依赖负向验证" },
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(body.error).toEqual({
|
||||||
|
code: "PAYMENT_ORDER_EXPORT_BLOCKED",
|
||||||
|
message: "银行制单生成失败",
|
||||||
|
details: { reason: "payment_order_generator_unavailable" },
|
||||||
|
});
|
||||||
|
expect(response.body).not.toContain("payment-order-biff8.txt");
|
||||||
|
expect(response.body).not.toContain("PAYMENT_ORDER_PYTHON_BIN");
|
||||||
|
expect(response.body).not.toContain("definitely-missing");
|
||||||
|
expect(logError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
diagnostic: expect.stringContaining("server/requirements/payment-order-biff8.txt"),
|
||||||
|
}),
|
||||||
|
"payment order generator unavailable",
|
||||||
|
);
|
||||||
|
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
if (originalPythonBin === undefined) delete process.env.PAYMENT_ORDER_PYTHON_BIN;
|
||||||
|
else process.env.PAYMENT_ORDER_PYTHON_BIN = originalPythonBin;
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let an admin without a finance business role export a payment order", async () => {
|
it("does not let an admin without a finance business role export a payment order", async () => {
|
||||||
const { app, tx } = await buildApp({
|
const { app, tx } = await buildApp({
|
||||||
actor: {
|
actor: {
|
||||||
|
|||||||
@@ -88,9 +88,19 @@ describe("payment order BIFF8 generator", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fails closed when the fixed Python runtime is unavailable", () => {
|
it("fails closed when the fixed Python runtime is unavailable", () => {
|
||||||
expect(() => generatePaymentOrderXls(config, buildBatch(), {
|
let caught;
|
||||||
pythonBin: "/definitely-missing/payment-order-python",
|
try {
|
||||||
})).toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
|
generatePaymentOrderXls(config, buildBatch(), {
|
||||||
|
pythonBin: "/definitely-missing/payment-order-python",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(PaymentOrderXlsError);
|
||||||
|
expect(caught).toMatchObject({ code: "payment_order_generator_unavailable" });
|
||||||
|
expect(caught.diagnostic).toContain("server/requirements/payment-order-biff8.txt");
|
||||||
|
expect(caught.diagnostic).toContain("PAYMENT_ORDER_PYTHON_BIN");
|
||||||
|
expect(JSON.stringify(caught)).not.toContain("payment-order-biff8.txt");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails closed when the fixed Python dependencies are unavailable", () => {
|
it("fails closed when the fixed Python dependencies are unavailable", () => {
|
||||||
@@ -102,8 +112,15 @@ describe("payment order BIFF8 generator", () => {
|
|||||||
"#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n",
|
"#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n",
|
||||||
{ mode: 0o700 },
|
{ mode: 0o700 },
|
||||||
);
|
);
|
||||||
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }))
|
let caught;
|
||||||
.toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
|
try {
|
||||||
|
generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython });
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toMatchObject({ code: "payment_order_generator_unavailable" });
|
||||||
|
expect(caught.diagnostic).toContain("server/requirements/payment-order-biff8.txt");
|
||||||
|
expect(JSON.stringify(caught)).not.toContain("PAYMENT_ORDER_PYTHON_BIN");
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,28 @@ describe("paymentProcessingProgress", () => {
|
|||||||
expect(assembler).not.toContain('label: "付款完成"');
|
expect(assembler).not.toContain('label: "付款完成"');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps workflow action stacking local while the shared panel owns sticky positioning", () => {
|
||||||
|
const templateSource = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, "../../../../client/src/components/patterns/BusinessDetailTemplate.tsx"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const workflowSource = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, "../../../../client/src/pages/WorkflowTaskDetailPage.tsx"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const cssSource = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, "../../../../client/src/index.css"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const workflowPanelRule = cssSource.match(/\.workflow-action-panel\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
|
||||||
|
expect(templateSource).toContain("detail-action-panel lg:sticky lg:top-24");
|
||||||
|
expect(workflowSource).toContain('className="workflow-action-panel"');
|
||||||
|
expect(workflowSource).not.toMatch(/className="workflow-action-panel[^"]*lg:top-/);
|
||||||
|
expect(workflowPanelRule).toContain("@apply relative z-30;");
|
||||||
|
expect(workflowPanelRule).not.toMatch(/lg:(?:sticky|top-)/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户