("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
}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx b/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx
index a691f4e1..590956e8 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx
@@ -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({
) : null}
- {hasOptions && isOpen && floatingPosition
+ {hasVisiblePanel && isOpen && floatingPosition
? createPortal(
void;
onPreviewImageChange: (event: ChangeEvent) => 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"
diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.test.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.test.ts
new file mode 100644
index 00000000..ad70381b
--- /dev/null
+++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.test.ts
@@ -0,0 +1,216 @@
+import {
+ buildExistingSpuCreateOptions,
+ buildExistingSpuCreateSkuItems,
+ getMaxSkuSequenceFromProduct,
+} from "./existingSpuCreateOptions";
+import {
+ createEmptyNonIntegratedOpeningReference,
+ type NonIntegratedProductRecord,
+ type NonIntegratedSkuItem,
+} from "../../core/nonIntegratedStorage";
+
+function createSkuItem(
+ overrides: Partial = {},
+): 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 {
+ 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]);
+ });
+});
diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.ts
index 8c48d85c..a1a8ad5c 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/existingSpuCreateOptions.ts
@@ -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();
+ 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,
+ };
+ });
+}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useBasicInfoFieldController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useBasicInfoFieldController.ts
index 36a48c9b..f599fe13 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useBasicInfoFieldController.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useBasicInfoFieldController.ts
@@ -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");
}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useExistingSpuCreateState.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useExistingSpuCreateState.ts
index 3021a73a..42313ec2 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useExistingSpuCreateState.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useExistingSpuCreateState.ts
@@ -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,
]);