feat(supply-chain): enrich archive supplier profiles
This commit is contained in:
parent
a69da95217
commit
28901d34b4
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
@ -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([
|
||||
'源沃家具',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user