feat(supply-chain): add stock monitor fields

This commit is contained in:
WuYongJie 2026-08-07 18:09:56 +08:00
parent 8b53afa468
commit b87bb1ccb4
4 changed files with 211 additions and 2 deletions

View File

@ -38,6 +38,8 @@ export type SupplyChainProductArchiveRow = {
boardProvider: string
penaltyStatus: string
moq: string
firstLeadTime: string
palletSpec: string
}
export const PRODUCT_ARCHIVE_SALES_STATUSES = ['销售', '停产', '退市'] as const

View File

@ -0,0 +1,143 @@
import sql from 'mssql'
export type JstStockMonitorKey = {
sku: string
barcode: string
}
export type JstStockMonitorProfile = {
sku: string
barcode: string
minimum: string
produceCycle: string
trayParameter: string
}
export interface JstStockMonitorRepository {
listByKeys(keys: readonly JstStockMonitorKey[]): Promise<JstStockMonitorProfile[]>
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 normalizedKeys(keys: readonly JstStockMonitorKey[]) {
const unique = new Map<string, JstStockMonitorKey>()
for (const key of keys) {
const sku = key.sku.trim()
const barcode = key.barcode.trim()
if (!sku && !barcode) continue
const identity = `${sku}\u0000${barcode}`
if (!unique.has(identity)) unique.set(identity, { sku, barcode })
}
return Array.from(unique.values()).slice(0, 200)
}
class JstStockMonitorRepositoryClass implements JstStockMonitorRepository {
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 listByKeys(keys: readonly JstStockMonitorKey[]) {
const stockKeys = normalizedKeys(keys)
if (stockKeys.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 barcodeParameters: string[] = []
const skuParameters: string[] = []
stockKeys.forEach((key, index) => {
if (key.barcode) {
const parameter = `barcode${index}`
request.input(parameter, sql.VarChar(100), key.barcode)
barcodeParameters.push(`@${parameter}`)
}
if (key.sku) {
const parameter = `sku${index}`
request.input(parameter, sql.VarChar(100), key.sku)
skuParameters.push(`@${parameter}`)
}
})
const conditions = [
barcodeParameters.length
? `LTRIM(RTRIM(CAST(sku_id AS varchar(100)))) IN (${barcodeParameters.join(', ')})`
: '',
skuParameters.length
? `LTRIM(RTRIM(CAST(sku AS varchar(100)))) IN (${skuParameters.join(', ')})`
: '',
].filter(Boolean)
const result = await request.query<JstStockMonitorProfile>(`
WITH ranked_stock AS (
SELECT
LTRIM(RTRIM(CAST(sku AS varchar(100)))) AS sku,
LTRIM(RTRIM(CAST(sku_id AS varchar(100)))) AS barcode,
LTRIM(RTRIM(COALESCE(CAST(minimum AS varchar(50)), ''))) AS minimum,
LTRIM(RTRIM(COALESCE(CAST(produceCycle AS varchar(50)), ''))) AS produceCycle,
LTRIM(RTRIM(COALESCE(CAST(trayParameter AS varchar(50)), ''))) AS trayParameter,
ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(CAST(sku_id AS varchar(100))))
ORDER BY [date] DESC, created DESC, id DESC
) AS row_rank
FROM [${schema}].[pm_stockMonitor]
WHERE ${conditions.join(' OR ')}
)
SELECT sku, barcode, minimum, produceCycle, trayParameter
FROM ranked_stock
WHERE row_rank = 1
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const jstStockMonitorRepository = new JstStockMonitorRepositoryClass()

View File

@ -3,6 +3,11 @@ jest.mock('@/server/repositories/jst-supplier-profile.repository', () => ({
listByNames: jest.fn().mockResolvedValue([]),
},
}))
jest.mock('@/server/repositories/jst-stock-monitor.repository', () => ({
jstStockMonitorRepository: {
listByKeys: jest.fn().mockResolvedValue([]),
},
}))
import {
SupplyChainProductArchiveService,
@ -88,9 +93,21 @@ describe('supply-chain product archive field mapping', () => {
},
]),
}
const stockMonitorRepository = {
listByKeys: jest.fn().mockResolvedValue([
{
sku: 'SKU-001',
barcode: 'BAR-001',
minimum: '12',
produceCycle: '7',
trayParameter: '40',
},
]),
}
const service = new SupplyChainProductArchiveService(
repository as never,
supplierProfileRepository,
stockMonitorRepository,
)
const page = await service.listPage({ page: 1, pageSize: 50 })
@ -120,11 +137,16 @@ describe('supply-chain product archive field mapping', () => {
settleType: '月结',
boardProvider: '简尚',
penaltyStatus: '都执行',
moq: '1',
moq: '12',
firstLeadTime: '7',
palletSpec: '40',
}),
)
expect(supplierProfileRepository.listByNames).toHaveBeenCalledWith([
'源沃家具',
])
expect(stockMonitorRepository.listByKeys).toHaveBeenCalledWith([
{ sku: 'SKU-001', barcode: 'BAR-001' },
])
})
})

View File

@ -15,6 +15,11 @@ import {
type JstSupplierProfile,
type JstSupplierProfileRepository,
} from '@/server/repositories/jst-supplier-profile.repository'
import {
jstStockMonitorRepository,
type JstStockMonitorProfile,
type JstStockMonitorRepository,
} from '@/server/repositories/jst-stock-monitor.repository'
const DAY_IN_MS = 24 * 60 * 60 * 1000
@ -30,6 +35,10 @@ function normalizeSupplierName(value: string) {
return value.trim().toLocaleLowerCase('zh-CN')
}
function normalizeStockKey(value: string) {
return value.trim().toLocaleUpperCase('zh-CN')
}
function supplierStatusLabel(code: string | undefined) {
if (code === '1') return '合作'
if (code === '2') return '不合作'
@ -40,6 +49,7 @@ function mapProductArchiveRow(
record: ProductArchiveRecord,
now: Date,
supplierProfile?: JstSupplierProfile,
stockProfile?: JstStockMonitorProfile,
) {
return {
id: record.id,
@ -80,7 +90,9 @@ function mapProductArchiveRow(
settleType: supplierProfile?.settlementMethod ?? '',
boardProvider: supplierProfile?.boardProvider ?? '',
penaltyStatus: supplierProfile?.penaltyStatus ?? '',
moq: record.minimumOrderQuantity,
moq: stockProfile?.minimum || record.minimumOrderQuantity,
firstLeadTime: stockProfile?.produceCycle ?? '',
palletSpec: stockProfile?.trayParameter ?? '',
} satisfies SupplyChainProductArchiveRow
}
@ -92,6 +104,10 @@ export class SupplyChainProductArchiveService {
JstSupplierProfileRepository,
'listByNames'
> = jstSupplierProfileRepository,
private readonly stockMonitorRepository: Pick<
JstStockMonitorRepository,
'listByKeys'
> = jstStockMonitorRepository,
) {}
async listPage(params: {
@ -120,12 +136,38 @@ export class SupplyChainProductArchiveService {
// Keep the archive usable when the external supplier source is temporarily unavailable.
}
let stockProfiles: JstStockMonitorProfile[] = []
try {
stockProfiles = await this.stockMonitorRepository.listByKeys(
records.map((record) => ({ sku: record.sku, barcode: record.barcode })),
)
} catch {
// Keep the archive usable when the external stock monitor source is temporarily unavailable.
}
const stockByBarcode = new Map(
stockProfiles
.filter((profile) => profile.barcode.trim())
.map((profile) => [normalizeStockKey(profile.barcode), profile]),
)
const stockBySku = new Map<string, JstStockMonitorProfile[]>()
for (const profile of stockProfiles) {
const key = normalizeStockKey(profile.sku)
if (!key) continue
const candidates = stockBySku.get(key) ?? []
candidates.push(profile)
stockBySku.set(key, candidates)
}
return {
rows: records.map((record) =>
mapProductArchiveRow(
record,
now,
supplierProfiles.get(normalizeSupplierName(record.supplierName)),
stockByBarcode.get(normalizeStockKey(record.barcode)) ??
(stockBySku.get(normalizeStockKey(record.sku))?.length === 1
? stockBySku.get(normalizeStockKey(record.sku))?.[0]
: undefined),
),
),
pageInfo: {