diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 264fd877..4b818c0d 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -481,6 +481,107 @@ describe("NonIntegratedDetailClient", () => { ); }); + it("migrates BOM rows from a temporary barcode to the completed barcode", async () => { + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-bom-barcode-migration", + spu: "SJ0384", + skuItems: [ + createSkuItem({ + id: "sku-sj0384-1", + model: "SJ0384K01", + skuCode: "SJ038401", + barcode: "SJ038401V01", + manufacturer: "", + manufacturerCode: "", + }), + ], + bomDetails: [ + { + styleKey: "K01", + skuId: "sku-sj0384-1", + barcode: "SJ038401V01", + rows: [ + { + id: "bom-temp-barcode-row", + category: "閰嶄欢", + partCode: "PJ-001", + partImage: "", + partName: "铰链", + structure: "", + material: "五金", + process: "", + remark: "", + specSize: "", + unit: "个", + factor: "", + unitPrice: "", + unitWeight: "", + quantity: "2", + }, + ], + hiddenRowIds: [], + syntheticUnitPriceOverrides: {}, + }, + ], + }), + ); + + render( + , + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "DNZ0001-bom-barcode-migration", + ); + expect(savedProduct?.bomDetails).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + styleKey: "K01", + skuId: "sku-sj0384-1", + barcode: "SJ038401V01", + rows: expect.arrayContaining([ + expect.objectContaining({ id: "bom-temp-barcode-row" }), + ]), + }), + ]), + ); + }); + + const skuRow = screen + .getByDisplayValue("SJ0384K01") + .closest("tr"); + expect(skuRow).not.toBeNull(); + const supplierInput = + within(skuRow!).getByPlaceholderText("搜索供应商名称"); + const actor = userEvent.setup(); + await actor.type(supplierInput, "兰考华兰家具"); + await actor.tab(); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "DNZ0001-bom-barcode-migration", + ); + expect(savedProduct?.bomDetails).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + styleKey: "K01", + skuId: "sku-sj0384-1", + barcode: "SJ038401N1280V01", + rows: expect.arrayContaining([ + expect.objectContaining({ id: "bom-temp-barcode-row" }), + ]), + }), + ]), + ); + }); + }); + it("creates a draft temporary code when saving a new demand without manual temporary code", async () => { const actor = userEvent.setup(); const { container } = render(); 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 4db0810a..f326fc94 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -2926,6 +2926,46 @@ function buildBomDetailStorageKey(styleKey: string, skuId?: string) { return skuId ? `${styleKey}${BOM_DETAIL_SKU_SEPARATOR}${skuId}` : styleKey; } +function normalizeBomBarcodeKey(value: string | undefined) { + return value?.trim().toUpperCase() ?? ""; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isBomBarcodeForSku(barcode: string, sku: NonIntegratedSkuItem) { + const normalizedBarcode = normalizeBomBarcodeKey(barcode); + if (!normalizedBarcode) return false; + + if (normalizeBomBarcodeKey(sku.barcode) === normalizedBarcode) { + return true; + } + + return buildAutoBarcodeSkuCandidates(sku.skuCode).some((candidateSkuCode) => + new RegExp(`^${escapeRegExp(candidateSkuCode)}[A-Z0-9]*V01$`).test( + normalizedBarcode, + ), + ); +} + +function findSkuForBomBarcode( + skuItems: NonIntegratedSkuItem[], + barcode: string | undefined, +) { + const normalizedBarcode = normalizeBomBarcodeKey(barcode); + if (!normalizedBarcode) return null; + + return skuItems.find((item) => isBomBarcodeForSku(normalizedBarcode, item)) ?? null; +} + +function buildBomDetailBarcodeStorageKey(styleKey: string, barcode?: string) { + const normalizedBarcode = normalizeBomBarcodeKey(barcode); + return normalizedBarcode + ? buildBomDetailStorageKey(styleKey, normalizedBarcode) + : ""; +} + function parseBomDetailStorageKey( detailKey: string, ): Pick { @@ -2947,6 +2987,103 @@ function hasBomDetailOverride(bomDetails: BomDetailMap, detailKey: string) { return Object.prototype.hasOwnProperty.call(bomDetails, detailKey); } +function findSkuForBomDetail( + skuItems: NonIntegratedSkuItem[], + detail: NonIntegratedBomDetail, +) { + const normalizedBarcode = normalizeBomBarcodeKey(detail.barcode ?? detail.skuId); + + return ( + findSkuForBomBarcode(skuItems, normalizedBarcode) ?? + (detail.skuId + ? skuItems.find((item) => item.id === detail.skuId) + : undefined) ?? + null + ); +} + +function resolveBomDetailStorageKey( + skuItems: NonIntegratedSkuItem[], + detail: NonIntegratedBomDetail, +) { + const matchedSku = findSkuForBomDetail(skuItems, detail); + const barcodeKey = buildBomDetailBarcodeStorageKey( + detail.styleKey, + matchedSku?.barcode ?? detail.barcode, + ); + + if (barcodeKey) return barcodeKey; + + return buildBomDetailStorageKey(detail.styleKey, detail.skuId); +} + +function mergeBomDetailState( + base: BomDetailState | undefined, + incoming: BomDetailState, +) { + if (!base) { + return incoming; + } + + const rowIds = new Set(base.rows.map((row) => row.id)); + return { + rows: [ + ...base.rows, + ...incoming.rows.filter((row) => !rowIds.has(row.id)), + ], + hiddenRowIds: Array.from( + new Set([...base.hiddenRowIds, ...incoming.hiddenRowIds]), + ), + syntheticUnitPriceOverrides: { + ...base.syntheticUnitPriceOverrides, + ...incoming.syntheticUnitPriceOverrides, + }, + }; +} + +function migrateBomDetailsToBarcodeKeys( + current: BomDetailMap, + skuItems: NonIntegratedSkuItem[], +) { + let changed = false; + const nextDetails: BomDetailMap = {}; + + for (const [detailKey, detailState] of Object.entries(current)) { + const { styleKey, skuId } = parseBomDetailStorageKey(detailKey); + if (!styleKey || !skuId) { + nextDetails[detailKey] = mergeBomDetailState( + nextDetails[detailKey], + detailState, + ); + continue; + } + + const matchedSku = + findSkuForBomBarcode(skuItems, skuId) ?? + skuItems.find((item) => item.id === skuId); + const nextKey = buildBomDetailBarcodeStorageKey( + styleKey, + matchedSku?.barcode, + ); + + if (!nextKey || nextKey === detailKey) { + nextDetails[detailKey] = mergeBomDetailState( + nextDetails[detailKey], + detailState, + ); + continue; + } + + changed = true; + nextDetails[nextKey] = mergeBomDetailState( + nextDetails[nextKey], + detailState, + ); + } + + return changed ? nextDetails : current; +} + function mergeBomRows( baseRows: NonIntegratedBomRow[], overrideRows: NonIntegratedBomRow[], @@ -3072,14 +3209,18 @@ function createInitialBomDetails(product: NonIntegratedProductRecord | null) { for (const detail of product?.bomDetails ?? []) { const sanitizedRows = sanitizeLegacyInheritedBomRows(detail.rows); hadLegacyCleanup ||= sanitizedRows.hadCleanup; + const detailKey = resolveBomDetailStorageKey( + product?.skuItems ?? [], + detail, + ); - nextBomDetails[buildBomDetailStorageKey(detail.styleKey, detail.skuId)] = { + nextBomDetails[detailKey] = mergeBomDetailState(nextBomDetails[detailKey], { rows: cloneBomRows(sanitizedRows.rows), hiddenRowIds: Array.from(new Set(detail.hiddenRowIds ?? [])), syntheticUnitPriceOverrides: { ...(detail.syntheticUnitPriceOverrides ?? {}), }, - }; + }); } for (const item of product?.skuItems ?? []) { @@ -5858,6 +5999,15 @@ function buildProductPayload( copiedFromSpu?: string, ): NonIntegratedProductRecord { const now = new Date().toISOString(); + const derivedSkuItemsForPayload = skuItems.map((item, index) => ({ + ...deriveSkuItem(item, index, formValues.spu, formValues), + model3dFileName: item.model3dFileName?.trim() ?? "", + model3dDataUrl: item.model3dDataUrl?.trim() ?? "", + model3dNodeOffsets: + item.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, + model3dNodeRotations: + item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + })); const normalizedStyleDetails = Object.entries(styleDetails) .filter(([styleKey]) => Boolean(styleKey)) .map(([styleKey, detail]) => ({ @@ -5887,11 +6037,23 @@ function buildProductPayload( packagingModel3dNodeRotations: detail.packagingModel3dNodeRotations, })); const firstStyleDetail = normalizedStyleDetails[0]; - const normalizedBomDetails = Object.entries(bomDetails) + const bomDetailsForPayload = migrateBomDetailsToBarcodeKeys( + bomDetails, + derivedSkuItemsForPayload, + ); + const normalizedBomDetails = Object.entries(bomDetailsForPayload) .map((entry) => { const [detailKey, detailState] = entry; const { styleKey, skuId } = parseBomDetailStorageKey(detailKey); if (!styleKey) return null; + const matchedSku = + skuId + ? findSkuForBomBarcode(derivedSkuItemsForPayload, skuId) ?? + derivedSkuItemsForPayload.find((item) => item.id === skuId) + : null; + const normalizedBarcode = normalizeBomBarcodeKey( + matchedSku?.barcode ?? skuId, + ); const sanitizedRows = sanitizeLegacyInheritedBomRows(detailState.rows).rows; const normalizedRows = sanitizedRows @@ -5990,7 +6152,8 @@ function buildProductPayload( return { styleKey, - ...(skuId ? { skuId } : {}), + ...(matchedSku?.id ? { skuId: matchedSku.id } : skuId ? { skuId } : {}), + ...(normalizedBarcode ? { barcode: normalizedBarcode } : {}), rows: normalizedRows, hiddenRowIds: normalizedHiddenRowIds, syntheticUnitPriceOverrides: normalizedSyntheticUnitPriceOverrides, @@ -6145,15 +6308,7 @@ function buildProductPayload( return normalizedRow; }), }, - skuItems: skuItems.map((item, index) => ({ - ...deriveSkuItem(item, index, formValues.spu, formValues), - model3dFileName: item.model3dFileName?.trim() ?? "", - model3dDataUrl: item.model3dDataUrl?.trim() ?? "", - model3dNodeOffsets: - item.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, - model3dNodeRotations: - item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, - })), + skuItems: derivedSkuItemsForPayload, factoryRows: normalizeFactoryRowsForPayload(factoryRows), pricingRows: normalizePricingRowsForPayload(pricingRows), sampleMeasurementRows: @@ -6824,10 +6979,10 @@ export default function NonIntegratedDetailClient({ ); const selectedBomStyleKey = selectedStyleKey; const selectedBomSkuKey = - selectedBomStyleKey && effectiveSelectedSupplementSkuId - ? buildBomDetailStorageKey( + selectedBomStyleKey && selectedSupplementSku + ? buildBomDetailBarcodeStorageKey( selectedBomStyleKey, - effectiveSelectedSupplementSkuId, + selectedSupplementSku.barcode, ) : ""; const hasSelectedBomOverride = @@ -8707,6 +8862,12 @@ export default function NonIntegratedDetailClient({ setActiveSharedBomRowIds([]); }, [effectiveSelectedSupplementSkuId, selectedBomStyleKey]); + useEffect(() => { + setBomDetails((current) => + migrateBomDetailsToBarcodeKeys(current, derivedSkuItems), + ); + }, [derivedSkuItems]); + useEffect(() => { if ( mode !== "edit" || @@ -9216,21 +9377,37 @@ export default function NonIntegratedDetailClient({ setBarcodeScannerSkuId(null); } + function applySkuSupplierName( + item: NonIntegratedSkuItem, + index: number, + value: string, + ) { + const matchedSupplier = resolveSupplierEntryByName(value); + const nextItem = { + ...item, + manufacturer: matchedSupplier ? matchedSupplier.name : value, + manufacturerCode: matchedSupplier ? matchedSupplier.code : "", + }; + const nextDerivedItem = deriveSkuItem( + nextItem, + index, + formValues.spu, + formValues, + ); + + return { + ...nextItem, + barcode: nextDerivedItem.barcode, + }; + } + function updateSkuSupplierName(id: string, value: string) { if (isReadOnly) return; - const matchedSupplier = resolveSupplierEntryByName(value); - setSkuItems((current) => - current.map((item) => { - if (item.id !== id) return item; - - return { - ...item, - manufacturer: matchedSupplier ? matchedSupplier.name : value, - manufacturerCode: matchedSupplier ? matchedSupplier.code : "", - }; - }), + current.map((item, index) => + item.id === id ? applySkuSupplierName(item, index, value) : item, + ), ); setSaveMessage(""); setSaveMessageType("success"); @@ -9240,17 +9417,10 @@ export default function NonIntegratedDetailClient({ if (isReadOnly) return; setSkuItems((current) => - current.map((item) => { + current.map((item, index) => { if (item.id !== id) return item; - const normalizedName = item.manufacturer.trim(); - const matchedSupplier = resolveSupplierEntryByName(normalizedName); - - return { - ...item, - manufacturer: matchedSupplier ? matchedSupplier.name : normalizedName, - manufacturerCode: matchedSupplier ? matchedSupplier.code : "", - }; + return applySkuSupplierName(item, index, item.manufacturer.trim()); }), ); setSaveMessage(""); diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts index a6b08e47..a258df46 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts @@ -415,6 +415,7 @@ export type NonIntegratedBomRow = { export type NonIntegratedBomDetail = { styleKey: string; skuId?: string; + barcode?: string; rows: NonIntegratedBomRow[]; hiddenRowIds?: string[]; syntheticUnitPriceOverrides?: Partial< @@ -1236,6 +1237,7 @@ function normalizeBomDetail(entry: unknown): NonIntegratedBomDetail | null { return { styleKey: item.styleKey, skuId: normalizeString(item.skuId) || undefined, + barcode: normalizeString(item.barcode).trim().toUpperCase() || undefined, rows: Array.isArray(item.rows) ? item.rows .map((row) => normalizeBomRow(row)) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index 112d213e..873b30c7 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -280,6 +280,103 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).toHaveBeenCalled() }) + it('keeps existing BOM details when an autosave submits an empty BOM payload', async () => { + const existingBomDetails = [ + { + styleKey: 'K01', + skuId: 'sku-sj0384-1', + barcode: 'SJ038401N1280V01', + rows: [ + { + id: 'bom-row-1', + category: '配件', + partCode: 'PJ-001', + partImage: '', + partName: '铰链', + structure: '', + material: '五金', + process: '', + remark: '', + specSize: '', + unit: '个', + factor: '', + unitPrice: '', + unitWeight: '', + quantity: '2', + }, + ], + hiddenRowIds: [], + syntheticUnitPriceOverrides: {}, + }, + ] + + const existingRecord = createRecord({ + payload: createPayload({ + bomDetails: existingBomDetails, + }), + }) + const incomingPayload = createPayload({ + bomDetails: [], + skuItems: [ + { + id: 'sku-sj0384-1', + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'SJ0384K01', + skuCode: 'SJ038401', + setCount: '1', + specName: '', + barcode: 'SJ038401N1280V01', + size: '', + color: '', + material: '', + attributeCode: 'A', + drawingId: 'SJ0384K01AV01', + installMethod: '', + executeStandard: '', + manufacturer: '青岛', + manufacturerCode: 'N1280', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + model3dNodeOffsets: {}, + model3dNodeRotations: {}, + }, + ], + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue(existingRecord as never) + mockedRepository.update.mockResolvedValue(existingRecord as never) + mockedRepository.findActiveById.mockResolvedValue(existingRecord as never) + + await intakeNonIntegratedProductService.save(incomingPayload, baseUser) + + const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as + | Record + | undefined + + expect(updatePayload?.bomDetails).toEqual([ + expect.objectContaining({ + styleKey: 'K01', + skuId: 'sku-sj0384-1', + barcode: 'SJ038401N1280V01', + rows: [ + expect.objectContaining({ + id: 'bom-row-1', + partCode: 'PJ-001', + partName: '铰链', + material: '五金', + quantity: '2', + }), + ], + }), + ]) + }) + it('preserves document workspace approval timestamps when saving', async () => { const payload = createPayload({ documentWorkspaceConfirmations: { diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 0c000974..4bfec7fa 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -333,6 +333,17 @@ function buildHydratedProductRecord( } } +function hasMeaningfulBomDetails( + bomDetails: NonIntegratedProductRecord['bomDetails'] | undefined, +) { + return (bomDetails ?? []).some( + (detail) => + detail.rows.length > 0 || + (detail.hiddenRowIds ?? []).length > 0 || + Object.keys(detail.syntheticUnitPriceOverrides ?? {}).length > 0, + ) +} + function buildPersistedProductPayload( productInput: unknown, existingPayload?: unknown, @@ -348,6 +359,22 @@ function buildPersistedProductPayload( const incomingRecord = isRecord(productInput) ? productInput : {} const existingRecord = isRecord(existingPayload) ? existingPayload : {} + const normalizedExistingProduct = isRecord(existingPayload) + ? normalizeNonIntegratedProductRecord( + existingPayload as Partial, + ) + : null + const existingBomDetails = normalizedExistingProduct?.bomDetails ?? [] + const hasIncomingBomDetails = Array.isArray(incomingRecord.bomDetails) + const shouldKeepExistingBomDetails = + hasMeaningfulBomDetails(existingBomDetails) && + normalizedProduct.bomDetails.length === 0 && + incomingRecord.clearBomDetails !== true + const bomDetails = shouldKeepExistingBomDetails + ? existingBomDetails + : hasIncomingBomDetails || normalizedProduct.bomDetails.length > 0 + ? normalizedProduct.bomDetails + : existingBomDetails const documentWorkspaceConfirmations = normalizeDocumentWorkspaceConfirmations( 'documentWorkspaceConfirmations' in incomingRecord @@ -368,9 +395,7 @@ function buildPersistedProductPayload( incomingRecord.uploadFiles, ) : mergeUploadFileMetadata( - normalizeNonIntegratedProductRecord( - existingPayload as Partial, - )?.uploadFiles ?? [], + normalizedExistingProduct?.uploadFiles ?? [], existingRecord.uploadFiles, existingRecord.uploadFiles, ) @@ -413,6 +438,7 @@ function buildPersistedProductPayload( ...existingRecord, ...incomingRecord, ...normalizedProduct, + bomDetails, uploadFiles, documentWorkspaceConfirmations, documentWorkspaceApprovedAt,