fix(intake): preserve bom details across barcode updates

This commit is contained in:
WuYongJie 2026-05-08 13:13:39 +08:00
parent 368b4427f0
commit 2f02681a69
5 changed files with 435 additions and 39 deletions

View File

@ -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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-bom-barcode-migration"
currentUserRole="SUPER_ADMIN"
/>,
);
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(<NonIntegratedDetailClient mode="create" />);

View File

@ -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<NonIntegratedBomDetail, "styleKey" | "skuId"> {
@ -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<NonIntegratedStyleDetail>(([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("");

View File

@ -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))

View File

@ -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<string, unknown>
| 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: {

View File

@ -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<NonIntegratedProductRecord>,
)
: 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<NonIntegratedProductRecord>,
)?.uploadFiles ?? [],
normalizedExistingProduct?.uploadFiles ?? [],
existingRecord.uploadFiles,
existingRecord.uploadFiles,
)
@ -413,6 +438,7 @@ function buildPersistedProductPayload(
...existingRecord,
...incomingRecord,
...normalizedProduct,
bomDetails,
uploadFiles,
documentWorkspaceConfirmations,
documentWorkspaceApprovedAt,