feat(supply-chain): map warehouse attributes from JST

This commit is contained in:
WuYongJie 2026-08-10 16:05:13 +08:00
parent 80c4dcc91b
commit c1ba901f7a
4 changed files with 148 additions and 4 deletions

View File

@ -28,6 +28,7 @@ export type SupplyChainProductArchiveRow = {
brand: string
orderType: string
inOutWarehouse: string
warehouseType: string
warehouseHead: string
storageType: string
isSeasonal: string

View File

@ -0,0 +1,96 @@
import sql from 'mssql'
export type JstWarehouseProfile = {
warehouseName: string
warehouseType: string
}
export interface JstWarehouseProfileRepository {
listByNames(names: readonly string[]): Promise<JstWarehouseProfile[]>
close(): Promise<void>
}
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<JstWarehouseProfile>(`
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()

View File

@ -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',
])
})
})

View File

@ -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<string, string>,
warehouseStorageTypes?: Map<string, string>,
warehouseHeads?: Map<string, string>,
) {
@ -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<string, string>()
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,
),