fix(intake): 直连报表库获取供应商目录

This commit is contained in:
WuYongJie 2026-07-24 10:54:23 +08:00
parent cb0f69d6c0
commit 63e3ab1504
7 changed files with 209 additions and 77 deletions

View File

@ -24,6 +24,16 @@ HR_SQLSERVER_PASSWORD=
HR_SQLSERVER_ENCRYPT=false
HR_SQLSERVER_TRUST_SERVER_CERTIFICATE=true
# 产品资料供应商与仓库目录(SQL Server 报表库,只读)
JST_PLANNING_SQLSERVER_HOST=
JST_PLANNING_SQLSERVER_PORT=1433
JST_PLANNING_SQLSERVER_DATABASE=
JST_PLANNING_SQLSERVER_USERNAME=
JST_PLANNING_SQLSERVER_PASSWORD=
JST_PLANNING_SQLSERVER_SCHEMA=rpt
JST_PLANNING_SQLSERVER_ENCRYPT=false
JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE=true
# 登录和令牌
JWT_SECRET=replace-with-random-secret
JWT_EXPIRES_IN=7d

View File

@ -8,13 +8,8 @@ export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const options = await jstPlanningDirectoryService.listOptions()
return jsonSuccess(options, 200, ctx.requestId)
} catch (error) {
console.error('Failed to load JST planning directory options', error)
return jsonSuccess({ suppliers: [], warehouses: [] }, 200, ctx.requestId)
}
const options = await jstPlanningDirectoryService.listOptions()
return jsonSuccess(options, 200, ctx.requestId)
},
{ requireAuth: true },
)

View File

@ -68,7 +68,9 @@ export function useNonIntegratedDetailBaseState(
initialProjectStatus: initialProductState.initialProjectStatus,
});
const onProductSavedRef = useLatestValueRef(onProductSaved);
const directoryOptions = useNonIntegratedDirectoryOptions();
const directoryOptions = useNonIntegratedDirectoryOptions({
onSupplierDirectoryError: detailToast.error,
});
const documentState = useNonIntegratedDocumentWorkspaceState({
activeDownstreamSkuBatch: skuState.activeDownstreamSkuBatch,
existingProduct: initialProductState.existingProduct,

View File

@ -0,0 +1,77 @@
import { renderHook, waitFor } from "@testing-library/react";
import { useNonIntegratedDirectoryOptions } from "./useNonIntegratedDirectoryOptions";
function createJsonResponse(payload: unknown, ok = true) {
return {
ok,
json: async () => payload,
} as Response;
}
describe("useNonIntegratedDirectoryOptions", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
it("使用接口返回的供应商目录,不加载静态兜底名单", async () => {
global.fetch = jest.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/intake/jst-directory-options") {
return createJsonResponse({
success: true,
data: {
suppliers: [{ name: "泉隆家居", code: "n3565" }],
warehouses: ["天津仓"],
},
});
}
return createJsonResponse({
success: true,
data: { names: [] },
});
}) as jest.Mock;
const { result } = renderHook(() => useNonIntegratedDirectoryOptions());
await waitFor(() => {
expect(result.current.supplierDirectoryOptions).toEqual([
{ name: "泉隆家居", code: "N3565" },
]);
});
expect(result.current.warehouseDirectoryOptions).toEqual(["天津仓"]);
});
it("接口失败时清空目录并把真实错误交给全局提示", async () => {
const onSupplierDirectoryError = jest.fn();
global.fetch = jest.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/intake/jst-directory-options") {
return createJsonResponse(
{
success: false,
error: { message: "上游报表库连接失败" },
},
false,
);
}
return createJsonResponse({
success: true,
data: { names: [] },
});
}) as jest.Mock;
const { result } = renderHook(() =>
useNonIntegratedDirectoryOptions({ onSupplierDirectoryError }),
);
await waitFor(() => {
expect(onSupplierDirectoryError).toHaveBeenCalledWith(
"上游报表库连接失败",
);
});
expect(result.current.supplierDirectoryOptions).toEqual([]);
expect(result.current.warehouseDirectoryOptions).toEqual([]);
});
});

View File

@ -1,13 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
getUniqueNonEmptyStrings,
normalizeJstSupplierOptions,
normalizeJstWarehouseOptions,
} from "./detailValueHelpers";
import {
SUPPLIER_DIRECTORY,
type SupplierDirectoryEntry,
} from "./supplierDirectory";
import type { SupplierDirectoryEntry } from "./supplierDirectory";
interface UseNonIntegratedDirectoryOptionsOptions {
onSupplierDirectoryError?: (message: string) => void;
}
async function fetchNameOptions(endpoint: string) {
try {
@ -24,7 +25,16 @@ async function fetchNameOptions(endpoint: string) {
}
}
export function useNonIntegratedDirectoryOptions() {
export function useNonIntegratedDirectoryOptions(
options: UseNonIntegratedDirectoryOptionsOptions = {},
) {
const onSupplierDirectoryErrorRef = useRef(
options.onSupplierDirectoryError,
);
useEffect(() => {
onSupplierDirectoryErrorRef.current = options.onSupplierDirectoryError;
}, [options.onSupplierDirectoryError]);
const [designerNameOptions, setDesignerNameOptions] = useState<string[]>([]);
const [planningOwnerNameOptions, setPlanningOwnerNameOptions] = useState<
string[]
@ -45,7 +55,7 @@ export function useNonIntegratedDirectoryOptions() {
string[]
>([]);
const [supplierDirectoryOptions, setSupplierDirectoryOptions] =
useState<SupplierDirectoryEntry[]>(SUPPLIER_DIRECTORY);
useState<SupplierDirectoryEntry[]>([]);
const [warehouseDirectoryOptions, setWarehouseDirectoryOptions] = useState<
string[]
>([]);
@ -99,13 +109,18 @@ export function useNonIntegratedDirectoryOptions() {
const response = await fetch("/api/intake/jst-directory-options", {
credentials: "same-origin",
});
if (!response.ok) return;
const payload = (await response.json()) as {
error?: { message?: string };
data?: {
suppliers?: unknown;
warehouses?: unknown;
};
};
if (!response.ok) {
throw new Error(
payload.error?.message || "供应商与仓库目录读取失败",
);
}
if (cancelled) return;
const suppliers = normalizeJstSupplierOptions(payload.data?.suppliers);
@ -113,12 +128,20 @@ export function useNonIntegratedDirectoryOptions() {
payload.data?.warehouses,
);
if (suppliers.length > 0) {
setSupplierDirectoryOptions(suppliers);
if (suppliers.length === 0) {
throw new Error("上游供应商目录为空,请检查数据源配置");
}
setSupplierDirectoryOptions(suppliers);
setWarehouseDirectoryOptions(warehouses);
} catch {
// Keep local fallback options when the planning database is unavailable.
} catch (error) {
if (cancelled) return;
setSupplierDirectoryOptions([]);
setWarehouseDirectoryOptions([]);
onSupplierDirectoryErrorRef.current?.(
error instanceof Error
? error.message
: "供应商与仓库目录读取失败",
);
}
}

View File

@ -1,4 +1,7 @@
import { mergeSupplierSourceRows } from '../jst-planning-directory.service'
import {
buildSqlServerDirectoryQueries,
mergeSupplierSourceRows,
} from '../jst-planning-directory.service'
describe('jst-planning-directory.service', () => {
it('js_supplier 没有供应商代码时,用 jst_supplier 兜底补齐代码', () => {
@ -30,4 +33,20 @@ describe('jst-planning-directory.service', () => {
{ name: '主表供应商', code: 'A001' },
])
})
it('SQL Server 目录读取固定使用 rpt 下的两张供应商视图', () => {
const queries = buildSqlServerDirectoryQueries()
expect(queries.supplierQuery).toContain('FROM [rpt].[js_supplier]')
expect(queries.supplierQuery).toContain('FROM [rpt].[jst_supplier]')
expect(queries.warehouseQuery).toContain('FROM [rpt].[jst_warehouseInfo]')
expect(queries.supplierQuery).not.toContain('[dbo]')
expect(queries.supplierQuery).not.toContain('OBJECT_ID')
})
it('SQL Server schema 必须是安全标识符', () => {
expect(() =>
buildSqlServerDirectoryQueries('rpt; DROP TABLE supplier'),
).toThrow('Unsafe SQL Server identifier')
})
})

View File

@ -23,6 +23,7 @@ const WAREHOUSE_TABLE = 'jst_warehouseInfo'
const CACHE_TTL_MS = 5 * 60 * 1000
const SUPPLIER_LIMIT = 5000
const WAREHOUSE_LIMIT = 2000
const DEFAULT_SQLSERVER_SOURCE_SCHEMA = 'rpt'
type MssqlConnectionPool = {
request(): {
@ -133,6 +134,14 @@ function uniqueNonEmpty(values: string[]) {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
function quoteSqlServerIdentifier(value: string) {
if (!/^[A-Za-z0-9_]+$/.test(value)) {
throw new Error(`Unsafe SQL Server identifier: ${value}`)
}
return `[${value}]`
}
function quoteMysqlIdentifier(value: string) {
if (!/^[A-Za-z0-9_]+$/.test(value)) {
throw new Error(`Unsafe MySQL identifier: ${value}`)
@ -357,70 +366,67 @@ async function loadMssql(): Promise<MssqlModule> {
return imported.default ?? imported
}
export function buildSqlServerDirectoryQueries(
sourceSchema = DEFAULT_SQLSERVER_SOURCE_SCHEMA,
) {
const schemaSql = quoteSqlServerIdentifier(sourceSchema)
const primarySupplierSql = `${schemaSql}.${quoteSqlServerIdentifier(
PRIMARY_SUPPLIER_TABLE,
)}`
const fallbackSupplierSql = `${schemaSql}.${quoteSqlServerIdentifier(
FALLBACK_SUPPLIER_TABLE,
)}`
const warehouseSql = `${schemaSql}.${quoteSqlServerIdentifier(WAREHOUSE_TABLE)}`
return {
supplierQuery: `
SELECT TOP (${SUPPLIER_LIMIT})
LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) AS name,
UPPER(LTRIM(RTRIM(COALESCE(CAST(scmcode AS nvarchar(255)), N'')))) AS code,
1 AS sourceRank
FROM ${primarySupplierSql}
WHERE scmname IS NOT NULL
AND LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) <> N''
UNION ALL
SELECT TOP (${SUPPLIER_LIMIT})
LTRIM(RTRIM(CAST(name AS nvarchar(255)))) AS name,
UPPER(LTRIM(RTRIM(COALESCE(CAST(supplier_code AS nvarchar(255)), N'')))) AS code,
2 AS sourceRank
FROM ${fallbackSupplierSql}
WHERE name IS NOT NULL
AND LTRIM(RTRIM(CAST(name AS nvarchar(255)))) <> N''
ORDER BY name, sourceRank;
`.trim(),
warehouseQuery: `
SELECT TOP (${WAREHOUSE_LIMIT})
LTRIM(RTRIM(CAST(name AS nvarchar(255)))) AS name
FROM ${warehouseSql}
WHERE name IS NOT NULL
AND LTRIM(RTRIM(CAST(name AS nvarchar(255)))) <> N''
ORDER BY name;
`.trim(),
}
}
async function fetchSqlServerDirectoryOptions(
connectionConfig: Record<string, unknown>,
): Promise<JstPlanningDirectoryOptions> {
const sql = await loadMssql()
const pool = await sql.connect(connectionConfig)
const sourceSchema =
readFirstEnv([
'JST_PLANNING_SQLSERVER_SCHEMA',
'JST_SQLSERVER_SCHEMA',
]) || DEFAULT_SQLSERVER_SOURCE_SCHEMA
const { supplierQuery, warehouseQuery } =
buildSqlServerDirectoryQueries(sourceSchema)
try {
const [supplierResult, warehouseResult] = await Promise.all([
pool.request().query<JstPlanningSupplierSourceRow>(
`
DECLARE @suppliers TABLE (
name nvarchar(255) NOT NULL,
code nvarchar(255) NOT NULL,
sourceRank int NOT NULL
);
IF OBJECT_ID(N'dbo.${PRIMARY_SUPPLIER_TABLE}', N'U') IS NOT NULL
BEGIN
INSERT INTO @suppliers (name, code, sourceRank)
SELECT TOP (${SUPPLIER_LIMIT})
LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) AS name,
UPPER(LTRIM(RTRIM(COALESCE(CAST(scmcode AS nvarchar(255)), N'')))) AS code,
1 AS sourceRank
FROM dbo.${PRIMARY_SUPPLIER_TABLE}
WHERE scmname IS NOT NULL
AND LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) <> N''
ORDER BY name;
END;
IF OBJECT_ID(N'dbo.${FALLBACK_SUPPLIER_TABLE}', N'U') IS NOT NULL
BEGIN
INSERT INTO @suppliers (name, code, sourceRank)
SELECT TOP (${SUPPLIER_LIMIT})
LTRIM(RTRIM(CAST(name AS nvarchar(255)))) AS name,
UPPER(LTRIM(RTRIM(COALESCE(CAST(supplier_code AS nvarchar(255)), N'')))) AS code,
2 AS sourceRank
FROM dbo.${FALLBACK_SUPPLIER_TABLE}
WHERE name IS NOT NULL
AND LTRIM(RTRIM(CAST(name AS nvarchar(255)))) <> N''
ORDER BY name;
END;
SELECT name, code, sourceRank
FROM @suppliers
ORDER BY name, sourceRank;
`.trim(),
),
pool.request().query<{ name: string }>(
`
IF OBJECT_ID(N'dbo.${WAREHOUSE_TABLE}', N'U') IS NOT NULL
BEGIN
SELECT TOP (${WAREHOUSE_LIMIT})
LTRIM(RTRIM(CAST(name AS nvarchar(255)))) AS name
FROM dbo.${WAREHOUSE_TABLE}
WHERE name IS NOT NULL
AND LTRIM(RTRIM(CAST(name AS nvarchar(255)))) <> N''
ORDER BY name;
END
ELSE
BEGIN
SELECT CAST(NULL AS nvarchar(255)) AS name WHERE 1 = 0;
END;
`.trim(),
),
pool
.request()
.query<JstPlanningSupplierSourceRow>(supplierQuery),
pool.request().query<{ name: string }>(warehouseQuery),
])
return {