feat(supply-chain): add stock monitor fields
This commit is contained in:
parent
8b53afa468
commit
b87bb1ccb4
@ -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
|
||||
|
||||
@ -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()
|
||||
@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user