fix: sync stock spu filters and imported sku codes

This commit is contained in:
WuYongJie 2026-05-19 12:08:11 +08:00
parent 668391f983
commit 273097f8b5
2 changed files with 271 additions and 100 deletions

View File

@ -4664,13 +4664,21 @@ function deriveSkuItem(
const autoSpecName = buildAutoSpecName(item);
const versionNo = normalizeSkuVersionNo(item.versionNo);
const normalizedItem = { ...item, versionNo };
const autoDrawingId = buildAutoDrawingId(normalizedItem);
const importedContentLocked = isImportedSkuContentLocked(item);
const resolvedSkuCode =
importedContentLocked && item.skuCode.trim()
? item.skuCode.trim().toUpperCase()
: finalSkuCode;
const autoDrawingId = buildAutoDrawingId({
...normalizedItem,
skuCode: resolvedSkuCode,
});
const autoBarcode = buildAutoBarcode(
finalSkuCode,
resolvedSkuCode,
item.manufacturerCode,
versionNo,
);
const importedLockedBarcode = isImportedSkuContentLocked(item)
const importedLockedBarcode = importedContentLocked
? item.barcode.trim().toUpperCase()
: "";
@ -4680,22 +4688,28 @@ function deriveSkuItem(
Number.isFinite(item.batchNo) && item.batchNo > 0
? Math.floor(item.batchNo)
: 1,
skuCode: finalSkuCode,
specName: autoSpecName,
skuCode: resolvedSkuCode,
specName:
importedContentLocked && item.specName.trim()
? item.specName.trim()
: autoSpecName,
barcode:
importedLockedBarcode ||
(shouldKeepCustomBarcode(
item.barcode,
finalSkuCode,
resolvedSkuCode,
item.manufacturerCode,
versionNo,
)
? item.barcode.trim()
: autoBarcode),
versionNo,
drawingId: shouldKeepCustomDrawingId(item.drawingId, normalizedItem)
? item.drawingId.trim()
: autoDrawingId,
drawingId:
importedContentLocked && item.drawingId.trim()
? item.drawingId.trim().toUpperCase()
: shouldKeepCustomDrawingId(item.drawingId, normalizedItem)
? item.drawingId.trim()
: autoDrawingId,
installMethod: item.installMethod.trim(),
executeStandard: item.executeStandard.trim(),
nationalStandardCompliant: normalizeNationalStandardCompliance(
@ -4816,13 +4830,22 @@ function createProductFactoryRowFromSku(
): NonIntegratedFactoryRow {
const confirmed = existingRow?.confirmed ?? false;
const savedSupplierName = existingRow?.supplierName?.trim() ?? "";
const supplierSource = confirmed
? sku.manufacturer || savedSupplierName
: savedSupplierName || sku.manufacturer;
const importedContentLocked = isImportedSkuContentLocked(sku);
const supplierSource =
importedContentLocked && !confirmed
? sku.manufacturer || savedSupplierName
: confirmed
? sku.manufacturer || savedSupplierName
: savedSupplierName || sku.manufacturer;
const supplier = normalizeFactorySupplier(supplierSource);
const supplierCode =
confirmed
? sku.manufacturerCode.trim().toUpperCase() || supplier.code
importedContentLocked && !confirmed
? supplier.code ||
sku.manufacturerCode.trim().toUpperCase() ||
existingRow?.supplierCode?.trim().toUpperCase() ||
""
: confirmed
? sku.manufacturerCode.trim().toUpperCase() || supplier.code
: existingRow?.supplierCode?.trim().toUpperCase() ||
supplier.code ||
sku.manufacturerCode.trim().toUpperCase();
@ -13495,7 +13518,9 @@ export default function NonIntegratedDetailClient({
setSkuItems((current) =>
current.map((item, index) =>
item.id === skuId
item.id === skuId &&
!isStockProductRecord &&
!isImportedSkuContentLocked(item)
? applySkuSupplierName(item, index, supplierName)
: item,
),
@ -13515,12 +13540,17 @@ export default function NonIntegratedDetailClient({
const supplier = normalizeFactorySupplier(value);
const supplierName = supplier.name || value;
const supplierCode = supplier.code;
const importedContentLocked =
derivedSkuItem !== undefined &&
isImportedSkuContentLocked(derivedSkuItem);
return {
...row,
supplierName,
supplierCode,
barcode: buildAutoBarcode(row.skuCode, supplierCode, versionNo),
barcode: importedContentLocked
? row.barcode.trim() || derivedSkuItem?.barcode.trim() || ""
: buildAutoBarcode(row.skuCode, supplierCode, versionNo),
};
}

View File

@ -29,6 +29,11 @@ type JstSkuSourceField =
type JstSkuSourceRow = Partial<Record<JstSkuSourceField, unknown>>
type JstSkuSourceGroup = {
sourceSpu: string
rows: JstSkuSourceRow[]
}
type JstSkuStockSyncOptions = {
apply?: boolean
batchSize?: number
@ -203,7 +208,7 @@ function mergeDefinedSourceFields<T extends Record<string, unknown>>(
return next
}
function buildSourceSkuItem(row: JstSkuSourceRow): NonIntegratedSkuItem {
function buildSourceSkuItem(row: JstSkuSourceRow, sequence: number): NonIntegratedSkuItem {
const skuId = normalizeSkuId(row.sku_id)
const name = normalizeSourceString(row.name)
const image = pickSourceImage(row)
@ -213,7 +218,7 @@ function buildSourceSkuItem(row: JstSkuSourceRow): NonIntegratedSkuItem {
return {
id: buildStableId('jst-stock-sku', skuId),
skuSequence: 1,
skuSequence: sequence,
batchNo: 1,
packageGroupId: '',
packageSuffix: '',
@ -246,25 +251,90 @@ function buildSourceSkuItem(row: JstSkuSourceRow): NonIntegratedSkuItem {
} as NonIntegratedSkuItem
}
function readSourceSpuKey(row: JstSkuSourceRow) {
return normalizeSourceString(row.i_id) || normalizeSkuId(row.sku_id)
}
function firstSourceValue(rows: JstSkuSourceRow[], key: JstSkuSourceField) {
for (const row of rows) {
const value = normalizeSourceString(row[key])
if (value) return value
}
return ''
}
function preferredSourceValue(
rows: JstSkuSourceRow[],
key: JstSkuSourceField,
preferredValue?: string,
) {
if (preferredValue) {
const matched = rows.some((row) => normalizeSourceString(row[key]) === preferredValue)
if (matched) return preferredValue
}
return firstSourceValue(rows, key)
}
function buildSourceSkuItems(rows: JstSkuSourceRow[]) {
const seenSkuIds = new Set<string>()
return rows
.map((row) => {
const skuId = normalizeSkuId(row.sku_id)
if (!skuId || seenSkuIds.has(skuId)) return null
seenSkuIds.add(skuId)
return row
})
.filter((row): row is JstSkuSourceRow => row !== null)
.map((row, index) => buildSourceSkuItem(row, index + 1))
}
function buildSourceProductPayload(
row: JstSkuSourceRow,
group: JstSkuSourceGroup,
syncedAt: string,
): (NonIntegratedProductRecord & Record<string, unknown>) | null {
const skuId = normalizeSkuId(row.sku_id)
const sourceSpu = normalizeSourceString(row.i_id)
const spu = skuId.toUpperCase()
const name = normalizeSourceString(row.name) || skuId
const image = pickSourceImage(row)
const imageName = pickImageName(skuId, image)
const color = normalizeSourceString(row.properties_value)
const material = normalizeSourceString(row.other_10)
const sourceSpu = group.sourceSpu.trim()
if (!sourceSpu) return null
const skuItems = buildSourceSkuItems(group.rows)
if (skuItems.length === 0) return null
const representativeRow =
group.rows.find((row) => normalizeSourceString(row.item_type) === '成品') ??
group.rows[0] ??
{}
const firstSku = skuItems[0]
const name = normalizeSourceString(representativeRow.name) || firstSku?.specName || sourceSpu
const image =
pickSourceImage(representativeRow) ||
skuItems.find((item) => item.image.trim())?.image.trim() ||
''
const imageName = pickImageName(firstSku?.barcode || sourceSpu, image)
const color = normalizeSourceString(representativeRow.properties_value) || firstSku?.color || ''
const material = normalizeSourceString(representativeRow.other_10) || firstSku?.material || ''
const size = extractSizeFromName(name)
const skuItem = buildSourceSkuItem(row)
const sourceCategory = firstSourceValue(group.rows, 'category')
const sourceItemType = preferredSourceValue(group.rows, 'item_type', '成品')
const hasOnSaleSku = group.rows.some(
(row) =>
normalizeSourceString(row.item_type) === '成品' &&
normalizeSourceString(row.other_5) === '销售' &&
normalizeSourceString(row.enabled) === '1',
)
const sourceSalesStatus = hasOnSaleSku
? '销售'
: firstSourceValue(group.rows, 'other_5')
const sourceEnabled = group.rows.some((row) => normalizeSourceString(row.enabled) === '1')
? '1'
: firstSourceValue(group.rows, 'enabled')
const firstSourceRow = group.rows[0] ?? {}
const rawPayload = {
id: buildStableId('jst-stock', skuId),
id: buildStableId('jst-stock', sourceSpu),
productDevelopmentKind: 'product',
spu,
spu: sourceSpu.toUpperCase(),
projectStatus: 'approved',
projectReview: {
reviewDate: '',
@ -281,7 +351,7 @@ function buildSourceProductPayload(
productImage: image,
productImageName: imageName,
productName: name,
temporaryCode: skuId,
temporaryCode: sourceSpu,
brand: BASIC_BRAND,
adaptableBrands: [BASIC_BRAND],
productCategory: '',
@ -338,7 +408,6 @@ function buildSourceProductPayload(
sampleRequired: '',
flatOutputPath: '',
output3dPath: '',
productCode: skuId,
size,
color,
material,
@ -366,8 +435,9 @@ function buildSourceProductPayload(
qualityInspectionSheet: {
rows: [],
},
skuItems: [skuItem],
skuBatchCount: 1,
productCode: sourceSpu,
skuItems,
skuBatchCount: Math.max(1, ...skuItems.map((item) => item.batchNo || 1)),
skuBatchFlowNos: {},
skuBatchChangeTypes: {},
factoryRows: [],
@ -390,28 +460,30 @@ function buildSourceProductPayload(
source: 'jst_sku',
sourceTable: JST_SKU_TABLE,
sourceSpu,
sourceSkuId: skuId,
sourceCategory: normalizeSourceString(row.category),
sourceItemType: normalizeSourceString(row.item_type),
sourceSalesStatus: normalizeSourceString(row.other_5),
sourceEnabled: normalizeSourceString(row.enabled),
sourceStockDisabled: normalizeSourceString(row.stock_disabled),
sourceSkuId: firstSku?.barcode ?? '',
sourceCategory,
sourceItemType,
sourceSalesStatus,
sourceEnabled,
sourceStockDisabled: firstSourceValue(group.rows, 'stock_disabled'),
skuCount: skuItems.length,
hasOnSaleSku,
sourceFields: {
sku_id: skuId,
sku_id: firstSku?.barcode ?? '',
i_id: sourceSpu,
name,
category: normalizeSourceString(row.category),
item_type: normalizeSourceString(row.item_type),
enabled: normalizeSourceString(row.enabled),
stock_disabled: normalizeSourceString(row.stock_disabled),
category: sourceCategory,
item_type: sourceItemType,
enabled: sourceEnabled,
stock_disabled: firstSourceValue(group.rows, 'stock_disabled'),
properties_value: color,
other_5: normalizeSourceString(row.other_5),
other_7: normalizeSourceString(row.other_7),
other_8: normalizeSourceString(row.other_8),
other_9: normalizeSourceString(row.other_9),
other_5: sourceSalesStatus,
other_7: normalizeSourceString(firstSourceRow.other_7),
other_8: normalizeSourceString(firstSourceRow.other_8),
other_9: normalizeSourceString(firstSourceRow.other_9),
other_10: material,
pic: normalizeSourceString(row.pic),
pic_big: normalizeSourceString(row.pic_big),
pic: normalizeSourceString(firstSourceRow.pic),
pic_big: normalizeSourceString(firstSourceRow.pic_big),
},
},
} as unknown as Partial<NonIntegratedProductRecord> & Record<string, unknown>
@ -432,27 +504,48 @@ function mergeExistingProductPayload(
(isRecord(existingPayload) ? existingPayload : {}) as Partial<NonIntegratedProductRecord>,
) ?? sourcePayload
const existingRecord = isRecord(existingPayload) ? existingPayload : {}
const sourceSku = sourcePayload.skuItems[0]
const existingSku =
existingProduct.skuItems.find((item) => item.id === sourceSku.id) ??
existingProduct.skuItems[0]
const nextSku = existingSku
? mergeDefinedSourceFields(existingSku, sourceSku, [
'image',
'imageName',
'model',
'skuCode',
'setCount',
'specName',
'barcode',
'size',
'color',
'material',
'drawingId',
'salesStatus',
])
: sourceSku
const remainingSkus = existingProduct.skuItems.filter((item) => item.id !== nextSku.id)
const matchedExistingSkuIds = new Set<string>()
const nextSourceSkus = sourcePayload.skuItems.map((sourceSku) => {
const existingSku = existingProduct.skuItems.find((item) => {
const matched =
item.id === sourceSku.id ||
item.sourceSkuId === sourceSku.sourceSkuId ||
(sourceSku.barcode.trim() && item.barcode.trim() === sourceSku.barcode.trim())
if (matched) matchedExistingSkuIds.add(item.id)
return matched
})
if (!existingSku) return sourceSku
const mergedSku = mergeDefinedSourceFields(existingSku, sourceSku, [
'image',
'imageName',
'model',
'skuCode',
'setCount',
'specName',
'barcode',
'size',
'color',
'material',
'drawingId',
'salesStatus',
'source',
'sourceTable',
'sourceSkuId',
])
return {
...mergedSku,
skuSequence: sourceSku.skuSequence,
source: 'jst_sku',
sourceTable: JST_SKU_TABLE,
sourceSkuId: sourceSku.sourceSkuId,
}
})
const remainingSkus = existingProduct.skuItems.filter(
(item) => !matchedExistingSkuIds.has(item.id),
)
const sourceOpeningReference = sourcePayload.openingReference
const nextOpeningReference = {
...existingProduct.openingReference,
@ -497,8 +590,11 @@ function mergeExistingProductPayload(
previewImageName: sourcePayload.previewImage
? sourcePayload.previewImageName
: existingProduct.previewImageName,
skuItems: [nextSku, ...remainingSkus],
skuBatchCount: existingProduct.skuBatchCount ?? 1,
skuItems: [...nextSourceSkus, ...remainingSkus],
skuBatchCount: Math.max(
existingProduct.skuBatchCount ?? 1,
sourcePayload.skuBatchCount ?? 1,
),
documentWorkspaceConfirmations: existingProduct.documentWorkspaceConfirmations,
documentWorkspaceApprovedAt: existingProduct.documentWorkspaceApprovedAt,
createdAt: existingProduct.createdAt,
@ -556,22 +652,65 @@ function buildSourceSelectColumns(columns: Set<string>) {
]
}
async function fetchSourceBatch(params: {
async function fetchSourceSpuKeys(params: {
connection: mysql.Connection
selectColumns: readonly string[]
offset: number
take: number
}) {
const selectSql = params.selectColumns.map((column) => `\`${column}\``).join(', ')
const [rows] = await params.connection.execute<RowDataPacket[]>(
`SELECT ${selectSql} FROM \`${JST_SKU_TABLE}\` WHERE \`sku_id\` IS NOT NULL AND TRIM(CAST(\`sku_id\` AS CHAR)) <> '' ORDER BY \`sku_id\` LIMIT ? OFFSET ?`,
`SELECT DISTINCT COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) AS source_spu
FROM \`${JST_SKU_TABLE}\`
WHERE \`sku_id\` IS NOT NULL
AND TRIM(CAST(\`sku_id\` AS CHAR)) <> ''
AND COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) <> ''
ORDER BY source_spu
LIMIT ? OFFSET ?`,
[params.take, params.offset],
)
return rows as JstSkuSourceRow[]
return rows
.map((row) => normalizeSourceString(row.source_spu))
.filter(Boolean)
}
async function findExistingStockProduct(id: string, spu: string, sourceSkuId: string) {
async function fetchSourceGroups(params: {
connection: mysql.Connection
selectColumns: readonly string[]
sourceSpuKeys: string[]
}) {
if (params.sourceSpuKeys.length === 0) return []
const selectSql = params.selectColumns.map((column) => `\`${column}\``).join(', ')
const placeholders = params.sourceSpuKeys.map(() => '?').join(', ')
const [rows] = await params.connection.execute<RowDataPacket[]>(
`SELECT ${selectSql},
COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) AS __source_spu
FROM \`${JST_SKU_TABLE}\`
WHERE \`sku_id\` IS NOT NULL
AND TRIM(CAST(\`sku_id\` AS CHAR)) <> ''
AND COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) IN (${placeholders})
ORDER BY __source_spu, \`sku_id\``,
params.sourceSpuKeys,
)
const rowsBySourceSpu = new Map<string, JstSkuSourceRow[]>()
;(rows as Array<JstSkuSourceRow & { __source_spu?: unknown }>).forEach((row) => {
const sourceSpu = normalizeSourceString(row.__source_spu) || readSourceSpuKey(row)
if (!sourceSpu) return
const nextRow = { ...row }
delete nextRow.__source_spu
rowsBySourceSpu.set(sourceSpu, [...(rowsBySourceSpu.get(sourceSpu) ?? []), nextRow])
})
return params.sourceSpuKeys
.map((sourceSpu) => ({
sourceSpu,
rows: rowsBySourceSpu.get(sourceSpu) ?? [],
}))
.filter((group) => group.rows.length > 0)
}
async function findExistingStockProduct(id: string, spu: string, sourceSpu: string) {
const existingById = await prisma.intakeNonIntegratedProduct.findUnique({
where: { id },
})
@ -583,14 +722,8 @@ async function findExistingStockProduct(id: string, spu: string, sourceSkuId: st
{ spu },
{
payload: {
path: ['metadata', 'sourceSkuId'],
equals: sourceSkuId,
},
},
{
payload: {
path: ['skuItems', '0', 'barcode'],
equals: sourceSkuId,
path: ['metadata', 'sourceSpu'],
equals: sourceSpu,
},
},
],
@ -601,14 +734,17 @@ async function findExistingStockProduct(id: string, spu: string, sourceSkuId: st
})
}
async function syncOneSourceRow(row: JstSkuSourceRow, apply: boolean, syncedAt: string) {
const sourcePayload = buildSourceProductPayload(row, syncedAt)
async function syncOneSourceGroup(group: JstSkuSourceGroup, apply: boolean, syncedAt: string) {
const sourcePayload = buildSourceProductPayload(group, syncedAt)
if (!sourcePayload) {
return 'skipped' as const
}
const sourceSkuId = normalizeSkuId(row.sku_id)
const existing = await findExistingStockProduct(sourcePayload.id, sourcePayload.spu, sourceSkuId)
const existing = await findExistingStockProduct(
sourcePayload.id,
sourcePayload.spu,
group.sourceSpu,
)
if (existing && existing.id !== sourcePayload.id && !isJstSkuPayload(existing.payload)) {
return 'conflicts' as const
@ -702,31 +838,36 @@ export async function syncJstSkuStockProducts(
const remaining = limit === null ? batchSize : Math.min(batchSize, limit - counters.scanned)
if (remaining <= 0) break
const rows = await fetchSourceBatch({
const sourceSpuKeys = await fetchSourceSpuKeys({
connection,
selectColumns,
offset,
take: remaining,
})
if (rows.length === 0) break
if (sourceSpuKeys.length === 0) break
for (const row of rows) {
const groups = await fetchSourceGroups({
connection,
selectColumns,
sourceSpuKeys,
})
for (const group of groups) {
counters.scanned += 1
try {
const result = await syncOneSourceRow(row, apply, new Date().toISOString())
const result = await syncOneSourceGroup(group, apply, new Date().toISOString())
counters[result] += 1
} catch (error) {
counters.skipped += 1
errors.push(
`${normalizeSkuId(row.sku_id) || '<empty sku_id>'}: ${
`${group.sourceSpu || '<empty source_spu>'}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
offset += rows.length
if (rows.length < remaining) break
offset += sourceSpuKeys.length
if (sourceSpuKeys.length < remaining) break
}
} finally {
await connection.end()