Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wu-product-development-type-filter
This commit is contained in:
commit
e7799331e0
@ -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
|
||||
|
||||
@ -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
|
||||
`)
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user