fix(intake): isolate SKU batch workbook imports

This commit is contained in:
WuYongJie 2026-08-26 13:17:16 +08:00
parent 2cd254fee5
commit d0ddbb6ccf
3 changed files with 250 additions and 38 deletions

View File

@ -0,0 +1,151 @@
import type { NonIntegratedSkuExportRow } from "@/lib/non-integrated-sku-export";
import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage";
import { createFormValuesFromProduct } from "./productFormValueHelpers";
import { createEmptySkuItem } from "./skuBrandHelpers";
import {
buildSkuItemsFromImportedWorkbookRows,
getSkuWorkbookImportBatchMetadata,
} from "./skuDerivationHelpers";
function createSkuItem(
id: string,
batchNo: number,
skuSequence: number,
overrides: Partial<NonIntegratedSkuItem> = {},
) {
return {
...createEmptySkuItem(),
id,
batchNo,
skuSequence,
...overrides,
};
}
function createImportRow(
overrides: Partial<NonIntegratedSkuExportRow> = {},
): NonIntegratedSkuExportRow {
return {
spu: "XG0012",
model: "XG0012",
skuCode: "XG001254",
barcode: "XG001254N160V01",
setCount: "1",
specName: "测试规格",
size: "",
color: "胡桃色",
material: "实木",
boardMaterialType: "",
versionNo: "V01",
attributeCode: "",
drawingId: "",
installMethod: "",
executeStandard: "",
nationalStandardCompliant: "",
manufacturer: "",
manufacturerCode: "N160",
minimumOrderQuantity: "",
salesStatus: "",
batchNo: "批次2",
batchChangeType: "新增SKU",
dingTalkFlowNo: "DD-2",
...overrides,
};
}
const formValues = {
...createFormValuesFromProduct(null),
spu: "XG0012",
};
describe("SKU workbook batch isolation", () => {
it("keeps historical batches unchanged and creates independent IDs for a new batch", () => {
const batchOneItems = [
createSkuItem("batch-1-sku-1", 1, 1, {
skuCode: "XG001251",
barcode: "XG001251N160V01",
}),
createSkuItem("batch-1-sku-2", 1, 2, {
skuCode: "XG001252",
barcode: "XG001252N160V01",
}),
];
const factoryConfirmedIds = new Set(batchOneItems.map((item) => item.id));
const result = buildSkuItemsFromImportedWorkbookRows({
currentItems: batchOneItems,
formValues,
rows: [
createImportRow(),
createImportRow({
skuCode: "XG001255",
barcode: "XG001255N160V01",
}),
],
targetBatchNo: 2,
});
expect(result.slice(0, 2)).toEqual(batchOneItems);
expect(result.slice(0, 2)[0]).toBe(batchOneItems[0]);
expect(result.slice(0, 2)[1]).toBe(batchOneItems[1]);
expect(result.slice(2)).toHaveLength(2);
expect(result.slice(2).every((item) => item.batchNo === 2)).toBe(true);
expect(
result.slice(2).every((item) => !factoryConfirmedIds.has(item.id)),
).toBe(true);
});
it("reuses IDs only from the batch being re-imported", () => {
const historicalItem = createSkuItem("batch-1-sku-1", 1, 1);
const currentBatchItem = createSkuItem("batch-2-sku-1", 2, 2, {
skuCode: "OLD-SKU",
});
const result = buildSkuItemsFromImportedWorkbookRows({
currentItems: [historicalItem, currentBatchItem],
formValues,
rows: [createImportRow({ skuCode: "UPDATED-SKU" })],
targetBatchNo: 2,
});
expect(result[0]).toBe(historicalItem);
expect(result[1].id).toBe(currentBatchItem.id);
expect(result[1].id).not.toBe(historicalItem.id);
expect(result[1].skuCode).toBe("UPDATED-SKU");
});
it("rejects a workbook whose explicit batch differs from the active batch", () => {
expect(() =>
getSkuWorkbookImportBatchMetadata({
currentBatchCount: 2,
rows: [createImportRow({ batchNo: "批次1" })],
targetBatchNo: 2,
}),
).toThrow("当前正在导入批次2,上传资料中包含批次1");
});
it("rejects an explicit batch value that cannot be identified", () => {
expect(() =>
getSkuWorkbookImportBatchMetadata({
currentBatchCount: 2,
rows: [createImportRow({ batchNo: "第二批" })],
targetBatchNo: 2,
}),
).toThrow("上传资料中的创建批次无法识别:第二批");
});
it("uses the active batch for blank batch cells and updates only its metadata", () => {
expect(
getSkuWorkbookImportBatchMetadata({
currentBatchCount: 2,
rows: [createImportRow({ batchNo: "" })],
targetBatchNo: 2,
}),
).toEqual({
batchChangeTypes: { "2": "新增SKU" },
batchFlowNos: { "2": "DD-2" },
nextBatchCount: 2,
});
});
});

View File

@ -278,23 +278,29 @@ export function buildSkuItemsFromImportedWorkbookRows({
currentItems,
formValues,
rows,
targetBatchNo,
}: {
currentItems: NonIntegratedSkuItem[];
formValues: FormValues;
rows: NonIntegratedSkuExportRow[];
targetBatchNo: number;
}) {
const nextItems = currentItems.map((item, index) =>
rows[index]
? buildSkuItemFromImportedRow(
rows[index],
item,
Number.isFinite(item.skuSequence) && item.skuSequence > 0
? item.skuSequence
: index + 1,
formValues,
)
: item,
);
if (rows.length === 0) return currentItems;
const normalizedTargetBatchNo = Math.max(1, Math.floor(targetBatchNo));
const isTargetBatchItem = (item: NonIntegratedSkuItem) =>
(Number.isFinite(item.batchNo) && item.batchNo > 0
? Math.floor(item.batchNo)
: 1) === normalizedTargetBatchNo;
const targetBatchItems = currentItems.filter(isTargetBatchItem);
const firstTargetBatchIndex = currentItems.findIndex(isTargetBatchItem);
const preservedItems = currentItems.filter((item) => !isTargetBatchItem(item));
const insertionIndex =
firstTargetBatchIndex < 0
? preservedItems.length
: currentItems
.slice(0, firstTargetBatchIndex)
.filter((item) => !isTargetBatchItem(item)).length;
let nextSequence =
currentItems.reduce(
(maxSequence, item, index) =>
@ -306,38 +312,84 @@ export function buildSkuItemsFromImportedWorkbookRows({
),
0,
) + 1;
for (let index = currentItems.length; index < rows.length; index += 1) {
nextItems.push(
buildSkuItemFromImportedRow(
rows[index],
undefined,
nextSequence,
formValues,
),
const importedTargetBatchItems = rows.map((row, index) => {
const existingItem = targetBatchItems[index];
const hasExistingSequence =
existingItem !== undefined &&
Number.isFinite(existingItem.skuSequence) &&
existingItem.skuSequence > 0;
const importedItem = buildSkuItemFromImportedRow(
row,
existingItem,
hasExistingSequence ? existingItem.skuSequence : nextSequence,
formValues,
);
nextSequence += 1;
}
return nextItems.length > 0 ? nextItems : currentItems;
if (!hasExistingSequence) nextSequence += 1;
return {
...importedItem,
batchNo: normalizedTargetBatchNo,
};
});
return [
...preservedItems.slice(0, insertionIndex),
...importedTargetBatchItems,
...preservedItems.slice(insertionIndex),
];
}
export function getSkuWorkbookImportBatchMetadata({
fallbackBatchNo,
currentBatchCount,
rows,
targetBatchNo,
}: {
fallbackBatchNo: number;
currentBatchCount: number;
rows: NonIntegratedSkuExportRow[];
targetBatchNo: number;
}) {
const normalizedTargetBatchNo = Math.max(1, Math.floor(targetBatchNo));
const explicitBatchValues = rows
.map((row) => row.batchNo.trim())
.filter((batchNo) => batchNo.length > 0);
const invalidBatchValues = Array.from(
new Set(explicitBatchValues.filter((batchNo) => !/\d+/.test(batchNo))),
);
if (invalidBatchValues.length > 0) {
throw new Error(
`上传资料中的创建批次无法识别:${invalidBatchValues.join("、")}。`,
);
}
const explicitBatchNos = explicitBatchValues.map((batchNo) =>
parseImportedSkuBatchNo(batchNo, normalizedTargetBatchNo),
);
const mismatchedBatchNos = Array.from(
new Set(
explicitBatchNos.filter(
(batchNo) => batchNo !== normalizedTargetBatchNo,
),
),
);
if (mismatchedBatchNos.length > 0) {
throw new Error(
`当前正在导入批次${normalizedTargetBatchNo},上传资料中包含${mismatchedBatchNos
.map((batchNo) => `批次${batchNo}`)
.join("、")},请使用当前批次的资料。`,
);
}
const batchFlowNos: Record<string, string> = {};
const batchChangeTypes: Record<string, string> = {};
const batchNos = rows.map((row) =>
parseImportedSkuBatchNo(row.batchNo, fallbackBatchNo),
const batchKey = String(normalizedTargetBatchNo);
const nextBatchCount = Math.max(
currentBatchCount,
normalizedTargetBatchNo,
1,
);
const nextBatchCount = Math.max(fallbackBatchNo, 1, ...batchNos);
rows.forEach((row, index) => {
const batchKey = String(batchNos[index] ?? 1);
rows.forEach((row) => {
batchFlowNos[batchKey] = row.dingTalkFlowNo.trim();
batchChangeTypes[batchKey] = row.batchChangeType.trim();
});

View File

@ -125,8 +125,15 @@ export function useSkuWorkbookController({
event: ChangeEvent<HTMLInputElement>,
) {
if (isReadOnly) return;
if (activeSkuBatchNo && isSkuBatchFactoryConfirmed(activeSkuBatchNo)) {
setSaveMessage(`批次${activeSkuBatchNo}已定厂,不能上传SKU资料。`);
const targetBatchNo = activeSkuBatchNo;
if (targetBatchNo === null) {
setSaveMessage("请先切到具体批次后再上传SKU资料。");
setSaveMessageType("error");
event.target.value = "";
return;
}
if (isSkuBatchFactoryConfirmed(targetBatchNo)) {
setSaveMessage(`批次${targetBatchNo}已定厂,不能上传SKU资料。`);
setSaveMessageType("error");
event.target.value = "";
return;
@ -142,20 +149,22 @@ export function useSkuWorkbookController({
if (rows.length === 0) {
throw new Error("上传资料中没有可导入的 SKU 行");
}
const importBatchMetadata = getSkuWorkbookImportBatchMetadata({
currentBatchCount: skuBatchCount,
rows,
targetBatchNo,
});
if (!(await ensureFormalSpuReadyForSkuEdit())) {
throw new Error("请先生成正式SPU后再导入SKU资料");
}
const importBatchMetadata = getSkuWorkbookImportBatchMetadata({
fallbackBatchNo: skuBatchCount,
rows,
});
setSkuItems((current) => {
return buildSkuItemsFromImportedWorkbookRows({
currentItems: current,
formValues,
rows,
targetBatchNo,
});
});
setSkuBatchCount(importBatchMetadata.nextBatchCount);