fix(intake): support stock spu sku continuation

This commit is contained in:
WuYongJie 2026-06-17 12:13:52 +08:00
parent ac24f0546c
commit 9bdc6ff0bd
8 changed files with 458 additions and 14 deletions

View File

@ -17,6 +17,7 @@ import {
cleanupSharedNonIntegratedAiWhiteBackgroundManifest,
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities,
generateSharedNonIntegratedAiWhiteBackgroundSession,
listSharedNonIntegratedProductPage,
previewSharedNonIntegratedDocumentImages,
saveSharedNonIntegratedProduct,
uploadSharedNonIntegratedAiWhiteBackgroundSession,
@ -181,6 +182,15 @@ jest.mock("../../../lib/model-preview-zip", () => ({
}));
jest.mock("./nonIntegratedApi", () => ({
listSharedNonIntegratedProductPage: jest.fn(async () => ({
products: [],
pageInfo: {
page: 1,
pageSize: 10,
total: 0,
totalPages: 1,
},
})),
previewSharedNonIntegratedDocumentImages: jest.fn(),
saveSharedNonIntegratedProduct: jest.fn(async (product) => product),
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities: jest.fn(),
@ -217,6 +227,9 @@ jest.mock("@/app/approval-workflows/approvalWorkflowApi", () => ({
const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked(
previewSharedNonIntegratedDocumentImages,
);
const mockedListSharedNonIntegratedProductPage = jest.mocked(
listSharedNonIntegratedProductPage,
);
const mockedFetchSharedNonIntegratedAiWhiteBackgroundCapabilities = jest.mocked(
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities,
);
@ -584,6 +597,16 @@ describe("NonIntegratedDetailClient", () => {
(parseZipFile as jest.Mock).mockReset();
(extractPreviewModelFromZip as jest.Mock).mockReset();
mockedPreviewSharedNonIntegratedDocumentImages.mockReset();
mockedListSharedNonIntegratedProductPage.mockReset();
mockedListSharedNonIntegratedProductPage.mockResolvedValue({
products: [],
pageInfo: {
page: 1,
pageSize: 10,
total: 0,
totalPages: 1,
},
});
mockedPreviewSharedNonIntegratedDocumentImages.mockResolvedValue({
imageUrls: ["/api/intake/non-integrated/assets/document-previews/test/page-1.jpg"],
});
@ -1175,6 +1198,12 @@ describe("NonIntegratedDetailClient", () => {
productCategory: "书桌",
}),
);
expect(saveCall[0].skuItems[0]).toEqual(
expect.objectContaining({
skuSequence: 10,
skuCode: "SZ023010",
}),
);
expect(saveCall[0].openingReference).toEqual(
expect.objectContaining({
salesCategory: "桌类",
@ -1189,6 +1218,92 @@ describe("NonIntegratedDetailClient", () => {
);
});
it("searches stock product details when selecting an existing SPU for a new SKU flow", async () => {
jest.useFakeTimers();
const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
(
globalThis as NonIntegratedRemoteAssetStorageTestGlobal
).__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true;
const stockProduct = createProductRecord({
id: "jst-stock-dnz0384",
spu: "DNZ0384",
productCode: "DNZ0384",
formalSpuAssigned: true,
metadata: {
source: "jst_sku",
sourceSpu: "DNZ0384",
},
productCategory: "涔︽",
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
productName: "DNZ0384瀛橀噺鍟嗗搧",
productCategory: "涔︽",
},
skuItems: [
createSkuItem({
skuSequence: 16,
skuCode: "JST-OLD-SKU",
barcode: "JST-OLD-BARCODE",
}),
],
});
mockedListSharedNonIntegratedProductPage.mockResolvedValue({
products: [stockProduct],
pageInfo: {
page: 1,
pageSize: 10,
total: 1,
totalPages: 1,
},
});
try {
render(
<NonIntegratedDetailClient
mode="create"
existingSpuProducts={[]}
currentUserRole="USER"
currentUserBusinessRoles={["DESIGN"]}
currentUserId="creator-user-id"
currentUserName="Creator"
/>,
);
await actor.click(screen.getByTestId("spu-create-mode-existing"));
const existingSpuSelect = await screen.findByTestId(
"existing-spu-create-select",
);
await actor.click(existingSpuSelect);
await actor.type(existingSpuSelect, "DNZ0384");
jest.advanceTimersByTime(300);
await waitFor(() =>
expect(mockedListSharedNonIntegratedProductPage).toHaveBeenCalledWith(
expect.objectContaining({
pageSize: 10,
productKind: "stock",
spu: "DNZ0384",
}),
),
);
const listboxes = await screen.findAllByRole("listbox");
const activeListbox = listboxes[listboxes.length - 1]!;
const stockOption = Array.from(
activeListbox.querySelectorAll<HTMLButtonElement>('[role="option"]'),
).find((option) => option.value === "DNZ0384");
expect(stockOption).toBeDefined();
await actor.click(stockOption!);
expect(
(screen.getByTestId("existing-spu-create-select") as HTMLInputElement)
.value,
).toBe("DNZ0384");
} finally {
jest.useRealTimers();
}
});
it("shows series component details from the barcode magnifier without leaking them into attribute columns", async () => {
const actor = userEvent.setup();
const legacyComponentCodes = "SPJ01/SPJ07/SPJ09/SPJ10";

View File

@ -186,6 +186,7 @@ import {
export { buildNonIntegratedBomDraftStorageKey } from "./workspaces/bom/bomDetailStateHelpers";
import { syncProductPatentRecordFromProduct } from "./workspaces/patent/productPatentSyncHelpers";
import {
listSharedNonIntegratedProductPage,
saveSharedNonIntegratedProduct,
} from "./nonIntegratedApi";
import { buildNonIntegratedBasicInfoOptions } from "./nonIntegratedBasicInfoOptions";
@ -432,6 +433,10 @@ export default function NonIntegratedDetailClient({
const [spuCreateMode, setSpuCreateMode] =
useState<SpuCreateMode>("auto");
const [selectedExistingSpu, setSelectedExistingSpu] = useState("");
const [existingSpuRemoteQuery, setExistingSpuRemoteQuery] = useState("");
const [remoteExistingSpuProducts, setRemoteExistingSpuProducts] = useState<
NonIntegratedProductRecord[]
>([]);
const [stockProductSpu, setStockProductSpu] = useState("");
const [formValues, setFormValues] = useState(() =>
createFormValuesFromProduct(existingProduct),
@ -1012,6 +1017,54 @@ export default function NonIntegratedDetailClient({
seriesDraftSkuOptions,
seriesNameOptions,
});
useEffect(() => {
if (
mode !== "create" ||
!canUseExistingSpuCreateMode ||
spuCreateMode !== "existing" ||
!shouldUseRemoteAssetStorage()
) {
setRemoteExistingSpuProducts([]);
return;
}
const normalizedQuery = existingSpuRemoteQuery.trim();
if (normalizedQuery.length < 2) {
setRemoteExistingSpuProducts([]);
return;
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
listSharedNonIntegratedProductPage({
page: 1,
pageSize: 10,
productDevelopmentKind: "product",
productKind: "stock",
spu: normalizedQuery,
})
.then((data) => {
if (!cancelled) {
setRemoteExistingSpuProducts(data.products);
}
})
.catch(() => {
if (!cancelled) {
setRemoteExistingSpuProducts([]);
}
});
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
canUseExistingSpuCreateMode,
existingSpuRemoteQuery,
mode,
spuCreateMode,
]);
const {
existingSpuCreateOptions,
existingSpuSelectOptions,
@ -1021,6 +1074,7 @@ export default function NonIntegratedDetailClient({
} = useExistingSpuCreateState({
canUseExistingSpuCreateMode,
existingSpuProducts,
remoteExistingSpuProducts,
selectedExistingSpu,
spuCreateMode,
spuEstimateRefreshToken,
@ -4593,6 +4647,7 @@ export default function NonIntegratedDetailClient({
selectedExistingSpuOption={
Boolean(selectedExistingSpuOption)
}
onExistingSpuQueryChange={setExistingSpuRemoteQuery}
seriesCreateEstimatedSpuLabel={
seriesCreateEstimatedSpuLabel
}

View File

@ -488,6 +488,8 @@ export function SearchableInlineSelect({
optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
includeEmptyOption,
emptyOptionDisabled = false,
maxVisibleOptions,
onQueryChange,
onChange,
}: {
ariaLabel: string;
@ -502,6 +504,8 @@ export function SearchableInlineSelect({
optionClassName?: string;
includeEmptyOption?: boolean;
emptyOptionDisabled?: boolean;
maxVisibleOptions?: number;
onQueryChange?: (value: string) => void;
onChange: (value: string) => void;
}) {
const listId = useId();
@ -528,15 +532,27 @@ export function SearchableInlineSelect({
const normalizedDisplayValue = displayValue.trim().toLowerCase();
const shouldFilterOptions =
normalizedQuery.length > 0 && normalizedQuery !== normalizedDisplayValue;
const visibleOptions = selectOptions.filter((option) => {
const filteredOptions = selectOptions.filter((option) => {
if (!shouldFilterOptions) return true;
return (
option.label.toLowerCase().includes(normalizedQuery) ||
option.value.toLowerCase().includes(normalizedQuery)
);
});
const hasOptions = !readOnly && selectOptions.length > 0;
const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef);
const normalizedMaxVisibleOptions =
typeof maxVisibleOptions === "number" && Number.isFinite(maxVisibleOptions)
? Math.max(1, Math.floor(maxVisibleOptions))
: null;
const visibleOptions = normalizedMaxVisibleOptions
? filteredOptions.slice(0, normalizedMaxVisibleOptions)
: filteredOptions;
const hasOptions =
!readOnly && (selectOptions.length > 0 || Boolean(onQueryChange));
const hasVisiblePanel = hasOptions && selectOptions.length > 0;
const floatingPosition = useFloatingPanelPosition(
isOpen && hasVisiblePanel,
containerRef,
);
useEffect(() => {
if (isOpen) return;
@ -597,7 +613,9 @@ export function SearchableInlineSelect({
onFocus={openPanel}
onChange={(event) => {
if (!hasOptions) return;
setQuery(event.target.value);
const nextQuery = event.target.value;
setQuery(nextQuery);
onQueryChange?.(nextQuery);
setIsOpen(true);
}}
onKeyDown={(event) => {
@ -641,7 +659,7 @@ export function SearchableInlineSelect({
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
{hasOptions && isOpen && floatingPosition
{hasVisiblePanel && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}

View File

@ -52,6 +52,7 @@ export function BasicInfoCreateSection({
onOpeningReferenceFieldChange,
onPreviewImageChange,
onPreviewProductImage,
onExistingSpuQueryChange,
onSpuCreateModeChange,
onStockProductSpuChange,
onUpdateField,
@ -89,6 +90,7 @@ export function BasicInfoCreateSection({
) => void;
onPreviewImageChange: (event: ChangeEvent<HTMLInputElement>) => void;
onPreviewProductImage: () => void;
onExistingSpuQueryChange?: (value: string) => void;
onSpuCreateModeChange: (value: SpuCreateMode) => void;
onStockProductSpuChange: (value: string) => void;
onUpdateField: (key: keyof FormValues, value: string) => void;
@ -347,6 +349,8 @@ export function BasicInfoCreateSection({
}
readOnly={isReadOnly}
onChange={onExistingSpuSelection}
onQueryChange={onExistingSpuQueryChange}
maxVisibleOptions={10}
inputClassName={`h-8 w-full rounded-apple-md border border-apple-separator bg-apple-bg-secondary px-2 pr-8 text-xs text-apple-label-primary outline-none ${
!selectedExistingSpuOption
? "text-apple-label-secondary"

View File

@ -0,0 +1,216 @@
import {
buildExistingSpuCreateOptions,
buildExistingSpuCreateSkuItems,
getMaxSkuSequenceFromProduct,
} from "./existingSpuCreateOptions";
import {
createEmptyNonIntegratedOpeningReference,
type NonIntegratedProductRecord,
type NonIntegratedSkuItem,
} from "../../core/nonIntegratedStorage";
function createSkuItem(
overrides: Partial<NonIntegratedSkuItem> = {},
): NonIntegratedSkuItem {
return {
id: "sku-1",
skuSequence: 1,
batchNo: 1,
packageGroupId: "",
packageSuffix: "",
image: "",
imageName: "",
model: "",
skuCode: "",
setCount: "1",
specName: "",
barcode: "",
size: "",
color: "",
material: "",
boardMaterialType: "",
versionNo: "V01",
attributeCode: "",
drawingId: "",
installMethod: "",
executeStandard: "",
nationalStandardCompliant: "",
manufacturer: "",
manufacturerCode: "",
minimumOrderQuantity: "",
salesStatus: "",
tagMaterial: "",
model3dFileName: "",
model3dDataUrl: "",
componentDetails: [],
...overrides,
};
}
function createProduct(
overrides: Partial<NonIntegratedProductRecord> = {},
): NonIntegratedProductRecord {
return {
id: "stock-product",
productDevelopmentKind: "product",
spu: "DNZ0384",
projectStatus: "approved",
projectReview: {
reviewDate: "",
reviewers: "",
designerReviewer: "",
purchaserReviewer: "",
planningReviewer: "",
operatorReviewer: "",
conclusion: "",
revisionNotes: "",
decidedAt: "",
},
openingReference: createEmptyNonIntegratedOpeningReference(),
year: "",
demandFlowNo: "",
processTemplate: "存量商品",
productCategory: "书桌",
salesCategory: "桌类",
productPlanning: "",
brandPlanning: "",
demandDepartment: "",
isSelfDevelopedPatentProduct: "",
patentOwner: "",
spuOwner: "",
needsClearanceHandling: "",
operationsOwner: "",
profitCenter: "",
salesChannel: "",
salesPlatform: "",
purchaseOwner: "",
goodsType: "",
materialType: "",
productionType: "",
manualProducer: "",
shippingMode: "",
visualDirector: "",
itemId: "",
priceBand: "",
outputRequirementType: "",
visualPlanning: "",
visualPlanningType: "",
photographer: "",
graphicDesigner: "",
designer3d: "",
sampleRequired: "",
flatOutputPath: "",
output3dPath: "",
brand: "其他品牌-普通大货",
adaptableBrands: ["其他品牌-普通大货"],
designer: "",
operator: "",
purchaser: "",
previewImage: "",
previewImageName: "",
productCode: "DNZ0384",
size: "",
color: "",
installMethod: "",
installSteps: "",
tagMaterial: "",
packageDetail: "",
craftDetail: "",
styleDetails: [],
bomDetails: [],
reviewSheet: {
version: "",
factory: "",
reviewDate: "",
reviewMethod: "",
productName: "",
productImage: "",
productImageName: "",
size: "",
material: "",
barcode: "",
reviewResult: "",
color: "",
issues: [],
},
qualityInspectionSheet: {
rows: [],
},
skuItems: [],
factoryRows: [],
pricingRows: [],
planningLayoutRows: [],
productLimitRows: [],
sampleMeasurementRows: [],
finishedSampleShipmentRows: [],
firstQualityRows: [],
uploadFiles: [],
documentWorkspaceConfirmations: {},
formalSpuAssigned: true,
createdAt: "2026-06-17T00:00:00.000Z",
updatedAt: "2026-06-17T00:00:00.000Z",
...overrides,
};
}
describe("existingSpuCreateOptions", () => {
it("includes stock product SPUs and keeps their max sku sequence", () => {
const options = buildExistingSpuCreateOptions([
createProduct({
id: "jst-stock-dnz0384",
spu: "DNZ0384",
productCode: "DNZ0384",
metadata: {
source: "jst_sku",
sourceSpu: "DNZ0384",
},
skuItems: [
createSkuItem({
skuSequence: 7,
skuCode: "DNZ038407",
}),
createSkuItem({
skuSequence: 12,
skuCode: "GBPM-DNZ038412",
}),
],
}),
]);
expect(options).toEqual([
expect.objectContaining({
value: "DNZ0384",
maxSkuSequence: 12,
}),
]);
});
it("uses structured skuSequence when imported stock codes do not match the SPU prefix", () => {
const maxSequence = getMaxSkuSequenceFromProduct(
createProduct({
skuItems: [
createSkuItem({
skuSequence: 16,
skuCode: "JST-OLD-SKU",
barcode: "JST-OLD-BARCODE",
}),
],
}),
"DNZ0384",
);
expect(maxSequence).toBe(16);
});
it("starts new SKU rows after the selected existing SPU max sequence", () => {
const rows = buildExistingSpuCreateSkuItems(
[
createSkuItem({ id: "sku-a", skuSequence: 1 }),
createSkuItem({ id: "sku-b", skuSequence: 2 }),
],
16,
);
expect(rows.map((row) => row.skuSequence)).toEqual([17, 18]);
});
});

View File

@ -3,6 +3,7 @@ import {
getNonIntegratedProductDisplayName,
NON_INTEGRATED_DEFAULT_BRAND,
normalizeNonIntegratedBrand,
stripNonIntegratedBrandPrefix,
type NonIntegratedProductRecord,
} from "../../core/nonIntegratedStorage";
@ -25,11 +26,13 @@ export const EMPTY_EXISTING_SPU_CREATE_OPTIONS: ExistingSpuCreateOption[] = [];
export function getSkuSequenceFromCode(skuCode: string, spu: string) {
const normalizedSkuCode = skuCode.trim().toUpperCase();
const normalizedSpu = spu.trim().toUpperCase();
if (!normalizedSkuCode || !normalizedSpu) return 0;
if (!normalizedSkuCode.startsWith(normalizedSpu)) return 0;
const normalizedSkuCodeBase = stripNonIntegratedBrandPrefix(normalizedSkuCode);
const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu);
if (!normalizedSkuCodeBase || !normalizedSpuBase) return 0;
if (!normalizedSkuCodeBase.startsWith(normalizedSpuBase)) return 0;
const sequenceMatch = normalizedSkuCode
.slice(normalizedSpu.length)
const sequenceMatch = normalizedSkuCodeBase
.slice(normalizedSpuBase.length)
.match(/^(\d+)/);
if (!sequenceMatch) return 0;
@ -46,8 +49,6 @@ export function getMaxSkuSequenceFromProduct(
getSkuSequenceFromCode(item.skuCode, spu),
getSkuSequenceFromCode(item.barcode, spu),
);
if (parsedSequence <= 0) return maxSequence;
const structuredSequence =
Number.isFinite(item.skuSequence) && item.skuSequence > 0
? Math.trunc(item.skuSequence)
@ -131,3 +132,30 @@ export function findExistingSpuCreateOption(
return options.find((option) => option.value === normalizedValue) ?? null;
}
export function buildExistingSpuCreateSkuItems(
items: NonIntegratedProductRecord["skuItems"],
maxExistingSkuSequence: number,
) {
const startSequence =
Number.isFinite(maxExistingSkuSequence) && maxExistingSkuSequence > 0
? Math.trunc(maxExistingSkuSequence) + 1
: 1;
const groupSequenceMap = new Map<string, number>();
let nextSequence = startSequence;
return items.map((item, index) => {
const groupKey = item.packageGroupId.trim() || item.id || String(index);
let sequence = groupSequenceMap.get(groupKey);
if (!sequence) {
sequence = nextSequence;
groupSequenceMap.set(groupKey, sequence);
nextSequence += 1;
}
return {
...item,
skuSequence: sequence,
};
});
}

View File

@ -16,6 +16,7 @@ import type {
ExistingSpuCreateOption,
SpuCreateMode,
} from "./existingSpuCreateOptions";
import { buildExistingSpuCreateSkuItems } from "./existingSpuCreateOptions";
import { BRAND_OPTIONS } from "./basicInfoConfig";
import type { FormValues } from "./productFormValueHelpers";
import { syncSkuItemsBrandPrefix } from "./skuBrandHelpers";
@ -235,6 +236,9 @@ export function useBasicInfoFieldController({
salesCategory: option.salesCategory || current.salesCategory,
productCategory: option.productCategory || current.productCategory,
}));
setSkuItems((current) =>
buildExistingSpuCreateSkuItems(current, option.maxSkuSequence),
);
setSaveMessage("");
setSaveMessageType("success");
}

View File

@ -13,6 +13,7 @@ import {
type UseExistingSpuCreateStateParams = {
canUseExistingSpuCreateMode: boolean;
existingSpuProducts: NonIntegratedProductRecord[];
remoteExistingSpuProducts?: NonIntegratedProductRecord[];
selectedExistingSpu: string;
spuCreateMode: SpuCreateMode;
spuEstimateRefreshToken: number;
@ -21,6 +22,7 @@ type UseExistingSpuCreateStateParams = {
export function useExistingSpuCreateState({
canUseExistingSpuCreateMode,
existingSpuProducts,
remoteExistingSpuProducts = [],
selectedExistingSpu,
spuCreateMode,
spuEstimateRefreshToken,
@ -28,13 +30,15 @@ export function useExistingSpuCreateState({
const existingSpuCreateOptions = useMemo(() => {
if (!canUseExistingSpuCreateMode) return EMPTY_EXISTING_SPU_CREATE_OPTIONS;
const sourceProducts = existingSpuProducts.length
? existingSpuProducts
: listNonIntegratedProducts();
const sourceProducts =
existingSpuProducts.length || remoteExistingSpuProducts.length
? [...existingSpuProducts, ...remoteExistingSpuProducts]
: listNonIntegratedProducts();
return buildExistingSpuCreateOptions(sourceProducts);
}, [
canUseExistingSpuCreateMode,
existingSpuProducts,
remoteExistingSpuProducts,
spuEstimateRefreshToken,
]);