feat(supply-chain): enrich archive supplier profiles

This commit is contained in:
WuYongJie 2026-08-07 17:42:56 +08:00
parent a69da95217
commit 28901d34b4
4 changed files with 207 additions and 7 deletions

View File

@ -31,8 +31,12 @@ export type SupplyChainProductArchiveRow = {
isSeasonal: string
replaceCount: string
supplier: string
supplierType: string
supplierStatus: string
purchaser: string
settleType: string
boardProvider: string
penaltyStatus: string
moq: string
}

View File

@ -0,0 +1,122 @@
import sql from 'mssql'
export type JstSupplierProfile = {
supplierName: string
supplierType: string
supplierStatusCode: string
purchaser: string
settlementMethod: string
boardProvider: string
penaltyStatus: string
}
export interface JstSupplierProfileRepository {
listByNames(names: readonly string[]): Promise<JstSupplierProfile[]>
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 JstSupplierProfileRepositoryClass implements JstSupplierProfileRepository {
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 supplierNames = normalizedNames(names)
if (supplierNames.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 = supplierNames.map((name, index) => {
const parameter = `supplierName${index}`
request.input(parameter, sql.NVarChar(255), name)
return `@${parameter}`
})
const result = await request.query<JstSupplierProfile>(`
WITH ranked_suppliers AS (
SELECT
LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) AS supplierName,
LTRIM(RTRIM(COALESCE(CAST(scmtype AS nvarchar(255)), N''))) AS supplierType,
LTRIM(RTRIM(COALESCE(CAST(coopersgn AS nvarchar(20)), N''))) AS supplierStatusCode,
LTRIM(RTRIM(COALESCE(CAST(buyer AS nvarchar(255)), N''))) AS purchaser,
LTRIM(RTRIM(COALESCE(CAST(paycyclesgn AS nvarchar(255)), N''))) AS settlementMethod,
LTRIM(RTRIM(COALESCE(CAST(undefined2 AS nvarchar(255)), N''))) AS boardProvider,
LTRIM(RTRIM(COALESCE(CAST(undefined1 AS nvarchar(255)), N''))) AS penaltyStatus,
ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(CAST(scmname AS nvarchar(255))))
ORDER BY local_updated DESC, Id DESC
) AS row_rank
FROM [${schema}].[js_supplier]
WHERE LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) IN (${parameters.join(', ')})
)
SELECT
supplierName,
supplierType,
supplierStatusCode,
purchaser,
settlementMethod,
boardProvider,
penaltyStatus
FROM ranked_suppliers
WHERE row_rank = 1
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const jstSupplierProfileRepository = new JstSupplierProfileRepositoryClass()

View File

@ -1,3 +1,9 @@
jest.mock('@/server/repositories/jst-supplier-profile.repository', () => ({
jstSupplierProfileRepository: {
listByNames: jest.fn().mockResolvedValue([]),
},
}))
import {
SupplyChainProductArchiveService,
} from '../supply-chain-product-archive.service'
@ -69,7 +75,23 @@ describe('supply-chain product archive field mapping', () => {
],
}),
}
const service = new SupplyChainProductArchiveService(repository as never)
const supplierProfileRepository = {
listByNames: jest.fn().mockResolvedValue([
{
supplierName: '源沃家具',
supplierType: '家具厂',
supplierStatusCode: '1',
purchaser: '千里眼',
settlementMethod: '月结',
boardProvider: '简尚',
penaltyStatus: '都执行',
},
]),
}
const service = new SupplyChainProductArchiveService(
repository as never,
supplierProfileRepository,
)
const page = await service.listPage({ page: 1, pageSize: 50 })
@ -92,10 +114,17 @@ describe('supply-chain product archive field mapping', () => {
orderType: '订单式',
storageType: '入仓',
supplier: '源沃家具',
purchaser: '宁珂',
settleType: '',
supplierType: '家具厂',
supplierStatus: '合作',
purchaser: '千里眼',
settleType: '月结',
boardProvider: '简尚',
penaltyStatus: '都执行',
moq: '1',
}),
)
expect(supplierProfileRepository.listByNames).toHaveBeenCalledWith([
'源沃家具',
])
})
})

View File

@ -10,6 +10,11 @@ import {
type ProductArchiveRecord,
type SupplyChainProductArchiveRepository,
} from '@/server/repositories/supply-chain-product-archive.repository'
import {
jstSupplierProfileRepository,
type JstSupplierProfile,
type JstSupplierProfileRepository,
} from '@/server/repositories/jst-supplier-profile.repository'
const DAY_IN_MS = 24 * 60 * 60 * 1000
@ -21,7 +26,21 @@ function decimalText(value: { toString(): string } | null) {
return value?.toString() ?? ''
}
function mapProductArchiveRow(record: ProductArchiveRecord, now: Date) {
function normalizeSupplierName(value: string) {
return value.trim().toLocaleLowerCase('zh-CN')
}
function supplierStatusLabel(code: string | undefined) {
if (code === '1') return '合作'
if (code === '2') return '不合作'
return ''
}
function mapProductArchiveRow(
record: ProductArchiveRecord,
now: Date,
supplierProfile?: JstSupplierProfile,
) {
return {
id: record.id,
image: record.image?.trim() ?? '',
@ -55,8 +74,12 @@ function mapProductArchiveRow(record: ProductArchiveRecord, now: Date) {
isSeasonal: record.isSeasonal,
replaceCount: record.replaceCount,
supplier: record.supplierName,
purchaser: record.purchaseOwner,
settleType: '',
supplierType: supplierProfile?.supplierType ?? '',
supplierStatus: supplierStatusLabel(supplierProfile?.supplierStatusCode),
purchaser: supplierProfile?.purchaser || record.purchaseOwner,
settleType: supplierProfile?.settlementMethod ?? '',
boardProvider: supplierProfile?.boardProvider ?? '',
penaltyStatus: supplierProfile?.penaltyStatus ?? '',
moq: record.minimumOrderQuantity,
} satisfies SupplyChainProductArchiveRow
}
@ -65,6 +88,10 @@ export class SupplyChainProductArchiveService {
constructor(
private readonly repository: SupplyChainProductArchiveRepository =
supplyChainProductArchiveRepository,
private readonly supplierProfileRepository: Pick<
JstSupplierProfileRepository,
'listByNames'
> = jstSupplierProfileRepository,
) {}
async listPage(params: {
@ -80,9 +107,27 @@ export class SupplyChainProductArchiveService {
filters: params.filters,
})
const now = new Date()
let supplierProfiles = new Map<string, JstSupplierProfile>()
try {
const profiles = await this.supplierProfileRepository.listByNames(
records.map((record) => record.supplierName),
)
supplierProfiles = new Map(
profiles.map((profile) => [normalizeSupplierName(profile.supplierName), profile]),
)
} catch {
// Keep the archive usable when the external supplier source is temporarily unavailable.
}
return {
rows: records.map((record) => mapProductArchiveRow(record, now)),
rows: records.map((record) =>
mapProductArchiveRow(
record,
now,
supplierProfiles.get(normalizeSupplierName(record.supplierName)),
),
),
pageInfo: {
page,
pageSize,