Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wu-product-development-type-filter

This commit is contained in:
WuYongJie 2026-08-10 14:37:50 +08:00
commit e7799331e0
6 changed files with 89 additions and 13 deletions

View File

@ -42,6 +42,10 @@ export type SupplyChainProductArchiveRow = {
moq: string
firstLeadTime: string
palletSpec: string
dailySales: string
sales3d: string
sales7d: string
sales15d: string
}
export const PRODUCT_ARCHIVE_SALES_STATUSES = ['销售', '停产', '退市'] as const

View File

@ -11,6 +11,10 @@ export type JstStockMonitorProfile = {
minimum: string
produceCycle: string
trayParameter: string
purchaseSale: string
threeAvgSale: string
sevenAvgSale: string
fifteenAvgSale: string
}
export interface JstStockMonitorRepository {
@ -119,6 +123,10 @@ class JstStockMonitorRepositoryClass implements JstStockMonitorRepository {
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,
LTRIM(RTRIM(COALESCE(CAST(purchaseSale AS varchar(50)), ''))) AS purchaseSale,
LTRIM(RTRIM(COALESCE(CAST(threeAvgSale AS varchar(50)), ''))) AS threeAvgSale,
LTRIM(RTRIM(COALESCE(CAST(sevenAvgSale AS varchar(50)), ''))) AS sevenAvgSale,
LTRIM(RTRIM(COALESCE(CAST(fifteenAvgSale AS varchar(50)), ''))) AS fifteenAvgSale,
ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(CAST(sku_id AS varchar(100))))
ORDER BY [date] DESC, created DESC, id DESC
@ -126,7 +134,16 @@ class JstStockMonitorRepositoryClass implements JstStockMonitorRepository {
FROM [${schema}].[pm_stockMonitor]
WHERE ${conditions.join(' OR ')}
)
SELECT sku, barcode, minimum, produceCycle, trayParameter
SELECT
sku,
barcode,
minimum,
produceCycle,
trayParameter,
purchaseSale,
threeAvgSale,
sevenAvgSale,
fifteenAvgSale
FROM ranked_stock
WHERE row_rank = 1
`)

View File

@ -102,6 +102,10 @@ describe('supply-chain product archive field mapping', () => {
minimum: '12',
produceCycle: '7',
trayParameter: '40',
purchaseSale: '35.39',
threeAvgSale: '0',
sevenAvgSale: '32.77',
fifteenAvgSale: '34.72',
},
]),
}
@ -147,6 +151,10 @@ describe('supply-chain product archive field mapping', () => {
moq: '12',
firstLeadTime: '7',
palletSpec: '40',
dailySales: '35.39',
sales3d: '0',
sales7d: '32.77',
sales15d: '34.72',
}),
)
expect(supplierProfileRepository.listByNames).toHaveBeenCalledWith([

View File

@ -1363,6 +1363,7 @@ describe('BomCostingScenarioService', () => {
jest.mocked(bomCostingScenarioRepository.findReferenceProfileRows).mockResolvedValue([
candidate({ id: 'a-board', category: 'BOARD', partName: '柜体板', sourceSku: 'WARDROBE-A', unitPrice: 20, amount: 380, lengthMm: 1_990, widthMm: 600 }),
candidate({ id: 'a-rail', category: 'ACCESSORY', partName: '移门轨道', sourceSku: 'WARDROBE-A', unitPrice: 10, amount: 10 }),
candidate({ id: 'a-hanging', category: 'ACCESSORY', partName: '衣通', sourceSku: 'WARDROBE-A', unitPrice: 10, amount: 10 }),
candidate({ id: 'b-board', category: 'BOARD', partName: '柜体板', sourceSku: 'WARDROBE-B', unitPrice: 20, amount: 400, lengthMm: 2_010, widthMm: 600 }),
candidate({ id: 'b-rail', category: 'ACCESSORY', partName: '移门轨道', sourceSku: 'WARDROBE-B', unitPrice: 10, amount: 10 }),
candidate({ id: 'c-board', category: 'BOARD', partName: '柜体板', sourceSku: 'WARDROBE-C', unitPrice: 20, amount: 420, lengthMm: 1_980, widthMm: 600 }),
@ -1399,6 +1400,15 @@ describe('BomCostingScenarioService', () => {
expect(structuredCalls.some((referenceSkus) => referenceSkus.includes('WARDROBE-A'))).toBe(true)
expect(structuredCalls.every((referenceSkus) => !referenceSkus.includes('SMALL-CABINET'))).toBe(true)
expect(structuredCalls.every((referenceSkus) => !referenceSkus.includes('BAD-SIBLING'))).toBe(true)
expect(bomCostingScenarioRepository.findStructureTokenReferenceRows).toHaveBeenCalledWith(
expect.objectContaining({
terms: expect.arrayContaining(['衣通', '移门']),
}),
)
const directPriceCalls = jest.mocked(bomCostingScenarioRepository.findReferenceCandidates).mock.calls
.map(([input]) => input.referenceSkus ?? [])
expect(directPriceCalls.some((referenceSkus) => referenceSkus.includes('WARDROBE-A'))).toBe(true)
expect(directPriceCalls.every((referenceSkus) => !referenceSkus.includes('SMALL-CABINET'))).toBe(true)
})
it('uses same-structure sibling SKU history before the wider company cohort', async () => {

View File

@ -52,6 +52,15 @@ const STRUCTURE_FEATURE_TERMS = [
'衣帽架', '挂衣架', '书架', '置物架', '梳妆台', '屏风',
'推拉门', '移门', '平开门', '折叠', '升降', '伸缩', '旋转', '抽屉', '衣通', '挂衣', '玻璃门',
] as const
const STRUCTURE_REFERENCE_ALIASES: Record<string, readonly string[]> = {
// The upstream BOM does not carry a product-family name on every row.
// Wardrobes are instead identifiable through their hanging hardware.
衣柜: ['衣通', '挂衣'],
衣橱: ['衣通', '挂衣'],
挂衣: ['衣通', '挂衣'],
移门: ['移门', '推拉门'],
推拉门: ['移门', '推拉门'],
}
const STRUCTURAL_PROFILE_CACHE_TTL_MS = 10 * 60 * 1_000
const structuralProfileRowsCache = new Map<string, {
expiresAt: number
@ -93,6 +102,18 @@ function referenceRowText(row: BomCostingScenarioReferenceCandidate) {
].join(' '))
}
function structureReferenceAliases(term: string) {
return STRUCTURE_REFERENCE_ALIASES[term] ?? [term]
}
function referenceTextMatchesStructureTerm(text: string, term: string) {
return structureReferenceAliases(term).some((alias) => text.includes(normalize(alias)))
}
function structureReferenceSearchTerms(terms: string[]) {
return Array.from(new Set(terms.flatMap(structureReferenceAliases))).slice(0, 8)
}
async function loadStructuralProfileRows(syncRunId: string, terms: string[]) {
if (terms.length === 0) return []
const cacheKey = `${syncRunId}\u0000${[...terms].sort().join('\u0000')}`
@ -107,9 +128,10 @@ async function loadStructuralProfileRows(syncRunId: string, terms: string[]) {
}
}
const promise = (async () => {
const searchTerms = structureReferenceSearchTerms(terms)
const matchedRows = await bomCostingScenarioRepository.findStructureTokenReferenceRows({
syncRunId,
terms,
terms: searchTerms,
})
const matchedProfiles = new Map<string, { sourceSku: string; hits: Set<string>; rows: number }>()
for (const row of matchedRows) {
@ -118,7 +140,7 @@ async function loadStructuralProfileRows(syncRunId: string, terms: string[]) {
const profile = matchedProfiles.get(key) ?? { sourceSku: row.sourceSku, hits: new Set<string>(), rows: 0 }
const text = referenceRowText(row)
for (const term of terms) {
if (text.includes(normalize(term))) profile.hits.add(term)
if (referenceTextMatchesStructureTerm(text, term)) profile.hits.add(term)
}
profile.rows += 1
matchedProfiles.set(key, profile)
@ -159,7 +181,7 @@ function rankStructurallySimilarReferenceSkus(input: {
}
const ranked = Array.from(profiles.values()).flatMap((rows) => {
const text = normalize(rows.map(referenceRowText).join(' '))
const matchedTerms = input.terms.filter((term) => text.includes(normalize(term)))
const matchedTerms = input.terms.filter((term) => referenceTextMatchesStructureTerm(text, term))
if (matchedTerms.length === 0) return []
const boardRows = rows.filter((row) => normalizeMaterialCategory(row.categoryCode || row.category) === 'BOARD')
const maximumBoardDimension = Math.max(0, ...boardRows.flatMap((row) => [
@ -1834,6 +1856,18 @@ export class BomCostingScenarioService {
sku.id,
availableSupplementalCategories,
)
const structureTerms = deriveStructureReferenceTerms(sku.name, scenarioLines)
const structuralProfileRows = syncRun
? await loadStructuralProfileRows(syncRun.id, structureTerms)
: []
const structurallySimilarReferenceSkus = rankStructurallySimilarReferenceSkus({
rows: structuralProfileRows,
terms: structureTerms,
targetHeightMm: decimalNumber(sku.heightMm),
})
const preferredReferenceSkus = structurallySimilarReferenceSkus.length > 0
? structurallySimilarReferenceSkus
: undefined
const rows = scenarioLines.map((line) => toDynamicRow(line, sku.externalSkuId, targetCategory))
const sameMaterialPrices: Record<string, DynamicBomPriceQuote> = {}
const substitutionPrices: Record<string, DynamicBomPriceQuote> = {}
@ -1868,6 +1902,7 @@ export class BomCostingScenarioService {
lengthMm: line.lengthMm === null ? null : Number(line.lengthMm),
widthMm: line.widthMm === null ? null : Number(line.widthMm),
targetMaterialNames: sameMaterialNames,
referenceSkus: preferredReferenceSkus,
}),
isReplacementLine(line.category, targetCategory)
? bomCostingScenarioRepository.findReferenceCandidates({
@ -1882,6 +1917,7 @@ export class BomCostingScenarioService {
lengthMm: line.lengthMm === null ? null : Number(line.lengthMm),
widthMm: line.widthMm === null ? null : Number(line.widthMm),
targetMaterialNames,
referenceSkus: preferredReferenceSkus,
})
: Promise.resolve([]),
]) : [[], []]
@ -1915,15 +1951,6 @@ export class BomCostingScenarioService {
]).map((value) => value.trim()).filter(Boolean),
))
const directSameReferenceSkus = quoteReferenceSkus(sameMaterialPrices)
const structureTerms = deriveStructureReferenceTerms(sku.name, scenarioLines)
const structuralProfileRows = syncRun
? await loadStructuralProfileRows(syncRun.id, structureTerms)
: []
const structurallySimilarReferenceSkus = rankStructurallySimilarReferenceSkus({
rows: structuralProfileRows,
terms: structureTerms,
targetHeightMm: decimalNumber(sku.heightMm),
})
const similarReferenceSkus = structurallySimilarReferenceSkus.length > 0
? structurallySimilarReferenceSkus
: directSameReferenceSkus

View File

@ -58,6 +58,12 @@ function mapProductArchiveRow(
warehouseStorageTypes?: Map<string, string>,
warehouseHeads?: Map<string, string>,
) {
const exactBarcodeStockProfile =
stockProfile &&
normalizeStockKey(stockProfile.barcode) === normalizeStockKey(record.barcode)
? stockProfile
: undefined
return {
id: record.id,
image: record.image?.trim() ?? '',
@ -104,6 +110,10 @@ function mapProductArchiveRow(
moq: stockProfile?.minimum || record.minimumOrderQuantity,
firstLeadTime: stockProfile?.produceCycle ?? '',
palletSpec: stockProfile?.trayParameter ?? '',
dailySales: exactBarcodeStockProfile?.purchaseSale ?? '',
sales3d: exactBarcodeStockProfile?.threeAvgSale ?? '',
sales7d: exactBarcodeStockProfile?.sevenAvgSale ?? '',
sales15d: exactBarcodeStockProfile?.fifteenAvgSale ?? '',
} satisfies SupplyChainProductArchiveRow
}