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,