diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index e114b710..88624904 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -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), }; } diff --git a/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts b/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts index e29e8f62..f5ae1433 100644 --- a/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts +++ b/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts @@ -29,6 +29,11 @@ type JstSkuSourceField = type JstSkuSourceRow = Partial> +type JstSkuSourceGroup = { + sourceSpu: string + rows: JstSkuSourceRow[] +} + type JstSkuStockSyncOptions = { apply?: boolean batchSize?: number @@ -203,7 +208,7 @@ function mergeDefinedSourceFields>( 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() + + 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) | 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 & Record @@ -432,27 +504,48 @@ function mergeExistingProductPayload( (isRecord(existingPayload) ? existingPayload : {}) as Partial, ) ?? 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() + 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) { ] } -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( - `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( + `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() + ;(rows as Array).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) || ''}: ${ + `${group.sourceSpu || ''}: ${ 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()