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 cde68ca8..389e4328 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -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 diff --git a/furniture-3d-system/src/server/repositories/jst-stock-monitor.repository.ts b/furniture-3d-system/src/server/repositories/jst-stock-monitor.repository.ts new file mode 100644 index 00000000..0c7cf8e9 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/jst-stock-monitor.repository.ts @@ -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 + 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 normalizedKeys(keys: readonly JstStockMonitorKey[]) { + const unique = new Map() + 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(` + 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() 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 d15beaa4..18fee8da 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 @@ -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' }, + ]) }) }) 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 f1b30803..b9420731 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 @@ -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() + 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: {