From c1ba901f7ab255a89deeee59011186b0c6c3cfe5 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 10 Aug 2026 16:05:13 +0800 Subject: [PATCH] feat(supply-chain): map warehouse attributes from JST --- .../src/lib/supply-chain-product-archive.ts | 1 + .../jst-warehouse-profile.repository.ts | 96 +++++++++++++++++++ ...pply-chain-product-archive.service.test.ts | 18 ++++ .../supply-chain-product-archive.service.ts | 37 ++++++- 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 furniture-3d-system/src/server/repositories/jst-warehouse-profile.repository.ts diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts index 3119c5f7..e9ee4150 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -28,6 +28,7 @@ export type SupplyChainProductArchiveRow = { brand: string orderType: string inOutWarehouse: string + warehouseType: string warehouseHead: string storageType: string isSeasonal: string diff --git a/furniture-3d-system/src/server/repositories/jst-warehouse-profile.repository.ts b/furniture-3d-system/src/server/repositories/jst-warehouse-profile.repository.ts new file mode 100644 index 00000000..d3f8cdcf --- /dev/null +++ b/furniture-3d-system/src/server/repositories/jst-warehouse-profile.repository.ts @@ -0,0 +1,96 @@ +import sql from 'mssql' + +export type JstWarehouseProfile = { + warehouseName: string + warehouseType: string +} + +export interface JstWarehouseProfileRepository { + listByNames(names: readonly string[]): Promise + close(): Promise +} + +function requiredEnv(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Missing required environment variable: ${name}`) + return value +} + +function booleanEnv(name: string, fallback: boolean) { + const value = process.env[name]?.trim().toLowerCase() + if (!value) return fallback + return value === 'true' || value === '1' +} + +function safeIdentifier(value: string, label: string) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new Error(`${label} is not a valid SQL Server identifier`) + } + return value +} + +function normalizedNames(names: readonly string[]) { + return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 200) +} + +class JstWarehouseProfileRepositoryClass implements JstWarehouseProfileRepository { + private pool: sql.ConnectionPool | null = null + + private async getPool() { + if (this.pool?.connected) return this.pool + + this.pool = await new sql.ConnectionPool({ + server: requiredEnv('JST_REPORT_DB_HOST'), + port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'), + database: requiredEnv('JST_REPORT_DB_NAME'), + user: requiredEnv('JST_REPORT_DB_USER'), + password: requiredEnv('JST_REPORT_DB_PASSWORD'), + options: { + encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true), + trustServerCertificate: booleanEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true), + }, + pool: { + min: 0, + max: 4, + idleTimeoutMillis: 30_000, + }, + requestTimeout: 30_000, + connectionTimeout: 15_000, + }).connect() + return this.pool + } + + async listByNames(names: readonly string[]) { + const warehouseNames = normalizedNames(names) + if (warehouseNames.length === 0) return [] + + const schema = safeIdentifier( + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + 'JST_REPORT_DB_SCHEMA', + ) + const pool = await this.getPool() + const request = pool.request() + const parameters = warehouseNames.map((name, index) => { + const parameter = `warehouseName${index}` + request.input(parameter, sql.NVarChar(255), name) + return `@${parameter}` + }) + + const result = await request.query(` + SELECT + LTRIM(RTRIM(CAST(name AS nvarchar(255)))) AS warehouseName, + LTRIM(RTRIM(COALESCE(CAST(remark2 AS nvarchar(1000)), N''))) AS warehouseType + FROM [${schema}].[jst_warehouseInfo] + WHERE LTRIM(RTRIM(CAST(name AS nvarchar(255)))) IN (${parameters.join(', ')}) + `) + return result.recordset + } + + async close() { + if (!this.pool) return + await this.pool.close() + this.pool = null + } +} + +export const jstWarehouseProfileRepository = new JstWarehouseProfileRepositoryClass() diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts index 24d3eada..b6db8f0c 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts @@ -8,6 +8,11 @@ jest.mock('@/server/repositories/jst-stock-monitor.repository', () => ({ listByKeys: jest.fn().mockResolvedValue([]), }, })) +jest.mock('@/server/repositories/jst-warehouse-profile.repository', () => ({ + jstWarehouseProfileRepository: { + listByNames: jest.fn().mockResolvedValue([]), + }, +})) import { SupplyChainProductArchiveService, @@ -109,6 +114,14 @@ describe('supply-chain product archive field mapping', () => { }, ]), } + const warehouseProfileRepository = { + listByNames: jest.fn().mockResolvedValue([ + { + warehouseName: 'WH-1', + warehouseType: '成品发货仓-真实库存', + }, + ]), + } const service = new SupplyChainProductArchiveService( repository as never, supplierProfileRepository, @@ -118,6 +131,7 @@ describe('supply-chain product archive field mapping', () => { { warehouseName: 'WH-1', warehouseHead: '负责人', storageType: 'INBOUND' }, ]), } as never, + warehouseProfileRepository, ) const page = await service.listPage({ page: 1, pageSize: 50 }) @@ -139,6 +153,7 @@ describe('supply-chain product archive field mapping', () => { volume: '139264', packType: '双包', orderType: '订单式', + warehouseType: '成品发货仓-真实库存', warehouseHead: '负责人', storageType: '入库', supplier: '源沃家具', @@ -163,5 +178,8 @@ describe('supply-chain product archive field mapping', () => { expect(stockMonitorRepository.listByKeys).toHaveBeenCalledWith([ { sku: 'SKU-001', barcode: 'BAR-001' }, ]) + expect(warehouseProfileRepository.listByNames).toHaveBeenCalledWith([ + 'WH-1', + ]) }) }) diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts index 78b3e863..cceb99d8 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts @@ -20,6 +20,10 @@ import { type JstStockMonitorProfile, type JstStockMonitorRepository, } from '@/server/repositories/jst-stock-monitor.repository' +import { + jstWarehouseProfileRepository, + type JstWarehouseProfileRepository, +} from '@/server/repositories/jst-warehouse-profile.repository' import { storageTypeLabel } from '@/lib/supply-chain-product-archive-values' import { supplyChainProductArchiveWarehouseConfigRepository, @@ -40,6 +44,10 @@ function normalizeSupplierName(value: string) { return value.trim().toLocaleLowerCase('zh-CN') } +function normalizeWarehouseName(value: string) { + return value.trim().toLocaleLowerCase('zh-CN') +} + function normalizeStockKey(value: string) { return value.trim().toLocaleUpperCase('zh-CN') } @@ -55,6 +63,7 @@ function mapProductArchiveRow( now: Date, supplierProfile?: JstSupplierProfile, stockProfile?: JstStockMonitorProfile, + warehouseTypes?: Map, warehouseStorageTypes?: Map, warehouseHeads?: Map, ) { @@ -94,9 +103,10 @@ function mapProductArchiveRow( brand: record.brand, orderType: record.orderType, inOutWarehouse: record.inOutWarehouse, - warehouseHead: warehouseHeads?.get(record.inOutWarehouse.trim().toLocaleLowerCase('zh-CN')) ?? '', + warehouseType: warehouseTypes?.get(normalizeWarehouseName(record.inOutWarehouse)) ?? '', + warehouseHead: warehouseHeads?.get(normalizeWarehouseName(record.inOutWarehouse)) ?? '', storageType: storageTypeLabel( - warehouseStorageTypes?.get(record.inOutWarehouse.trim().toLocaleLowerCase('zh-CN')), + warehouseStorageTypes?.get(normalizeWarehouseName(record.inOutWarehouse)), ), isSeasonal: record.isSeasonal, replaceCount: record.replaceCount, @@ -133,6 +143,10 @@ export class SupplyChainProductArchiveService { SupplyChainProductArchiveWarehouseConfigRepository, 'listActive' > = supplyChainProductArchiveWarehouseConfigRepository, + private readonly warehouseProfileRepository: Pick< + JstWarehouseProfileRepository, + 'listByNames' + > = jstWarehouseProfileRepository, ) {} async listPage(params: { @@ -151,13 +165,13 @@ export class SupplyChainProductArchiveService { const warehouseConfigs = await this.warehouseConfigRepository.listActive() const warehouseStorageTypes = new Map( warehouseConfigs.map((item) => [ - item.warehouseName.trim().toLocaleLowerCase('zh-CN'), + normalizeWarehouseName(item.warehouseName), item.storageType, ]), ) const warehouseHeads = new Map( warehouseConfigs.map((item) => [ - item.warehouseName.trim().toLocaleLowerCase('zh-CN'), + normalizeWarehouseName(item.warehouseName), item.warehouseHead.trim(), ]), ) @@ -182,6 +196,20 @@ export class SupplyChainProductArchiveService { } catch { // Keep the archive usable when the external stock monitor source is temporarily unavailable. } + let warehouseTypes = new Map() + try { + const profiles = await this.warehouseProfileRepository.listByNames( + records.map((record) => record.inOutWarehouse), + ) + warehouseTypes = new Map( + profiles.map((profile) => [ + normalizeWarehouseName(profile.warehouseName), + profile.warehouseType, + ]), + ) + } catch { + // Keep the archive usable when the external warehouse source is temporarily unavailable. + } const stockByBarcode = new Map( stockProfiles .filter((profile) => profile.barcode.trim()) @@ -206,6 +234,7 @@ export class SupplyChainProductArchiveService { (stockBySku.get(normalizeStockKey(record.sku))?.length === 1 ? stockBySku.get(normalizeStockKey(record.sku))?.[0] : undefined), + warehouseTypes, warehouseStorageTypes, warehouseHeads, ),