fix(intake): 直连报表库获取供应商目录
This commit is contained in:
parent
cb0f69d6c0
commit
63e3ab1504
@ -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
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
: "供应商与仓库目录读取失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user