diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 2bad8281..da3a11c0 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -35,6 +35,11 @@ import { saveNonIntegratedProduct, type NonIntegratedProductRecord, } from "./non-integrated/nonIntegratedStorage"; +import { + deleteSharedNonIntegratedProduct, + listSharedNonIntegratedProducts, + saveSharedNonIntegratedProduct, +} from "./non-integrated/nonIntegratedApi"; import type { CategoryDTO, DraftGroupDetailDTO, @@ -70,6 +75,7 @@ interface IntakeClientSelectorProps { materialLibraryFinishes: MaterialDTO[]; materialLibraryCategories: MaterialCategoryDTO[]; materialLibraryDictionaries: SubstrateDictionaryBundleDTO; + initialNonIntegratedProducts: NonIntegratedProductRecord[]; } interface IntakeClientEditorProps { @@ -91,6 +97,7 @@ interface IntakeClientEditorProps { materialLibraryFinishes: MaterialDTO[]; materialLibraryCategories: MaterialCategoryDTO[]; materialLibraryDictionaries: SubstrateDictionaryBundleDTO; + initialNonIntegratedProducts: NonIntegratedProductRecord[]; } type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps; @@ -861,9 +868,14 @@ function IntegratedPendingDetail({ ); } -function NonIntegratedPendingDetail() { +function NonIntegratedPendingDetail({ + initialProducts, +}: { + initialProducts: NonIntegratedProductRecord[]; +}) { const router = useRouter(); - const [products, setProducts] = useState([]); + const [products, setProducts] = + useState(initialProducts); const [previewProduct, setPreviewProduct] = useState<{ src: string; alt: string; @@ -886,8 +898,14 @@ function NonIntegratedPendingDetail() { operator: "", purchaser: "", }); + const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; useEffect(() => { + if (shouldUseSharedNonIntegratedApi) { + setProducts(initialProducts); + return; + } + function syncProducts() { setProducts(listNonIntegratedProducts()); } @@ -896,17 +914,31 @@ function NonIntegratedPendingDetail() { window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); return () => window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); - }, []); + }, [initialProducts, shouldUseSharedNonIntegratedApi]); - function handleDelete(product: NonIntegratedProductRecord) { + async function handleDelete(product: NonIntegratedProductRecord) { const productLabel = product.spu?.trim() || "当前产品"; if (!window.confirm(`确认删除“${productLabel}”吗?\n删除后不可恢复。`)) { return; } - deleteNonIntegratedProduct(product.id); - setProducts(listNonIntegratedProducts()); + try { + if (shouldUseSharedNonIntegratedApi) { + await deleteSharedNonIntegratedProduct(product.id); + setProducts((current) => + current.filter((currentProduct) => currentProduct.id !== product.id), + ); + return; + } + + deleteNonIntegratedProduct(product.id); + setProducts(listNonIntegratedProducts()); + } catch (error) { + window.alert( + error instanceof Error ? error.message : "删除失败,请稍后再试", + ); + } } function getAvailableCopyBrands(product: NonIntegratedProductRecord) { @@ -947,8 +979,14 @@ function NonIntegratedPendingDetail() { copyDialogState.product, copyDialogState.targetBrand, ); - const savedProduct = saveNonIntegratedProduct(copiedProduct); - setProducts(listNonIntegratedProducts()); + const savedProduct = shouldUseSharedNonIntegratedApi + ? await saveSharedNonIntegratedProduct(copiedProduct) + : saveNonIntegratedProduct(copiedProduct); + setProducts( + shouldUseSharedNonIntegratedApi + ? await listSharedNonIntegratedProducts() + : listNonIntegratedProducts(), + ); setCopyDialogState(null); router.push( `/intake/non-integrated/${encodeURIComponent(savedProduct.id)}`, @@ -1494,7 +1532,9 @@ export default function IntakeClient(props: IntakeClientProps) { pendingEntryTab === "integrated" ? ( ) : ( - + ); } else if (activeSection === "bom") { leftContent = ( @@ -1541,7 +1581,9 @@ export default function IntakeClient(props: IntakeClientProps) { pendingEntryTab === "integrated" ? ( ) : ( - + ); } else if (activeSection === "bom") { leftContent = ( 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 d0361279..baabce3c 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 @@ -6,7 +6,10 @@ import type { ReactNode } from "react"; import NonIntegratedDetailClient from "./NonIntegratedDetailClient"; import { createDefaultDocumentWorkspaceConfirmations, + getNonIntegratedProductById, NON_INTEGRATED_STORAGE_KEY, + saveNonIntegratedProduct, + type NonIntegratedSkuItem, type NonIntegratedProductRecord, } from "./nonIntegratedStorage"; import { parseZipFile } from "@/lib/zip-parser"; @@ -100,6 +103,39 @@ function createProductRecord( }; } +function createSkuItem( + overrides: Partial = {}, +): NonIntegratedSkuItem { + return { + id: "sku-test-1", + skuSequence: 1, + packageGroupId: "", + packageSuffix: "", + image: "", + imageName: "", + model: "SZ0230K01", + skuCode: "SZ023001", + setCount: "1", + specName: "款式一 140/60/75", + barcode: "", + size: "140/60/75", + color: "胡桃色", + material: "橡胶木", + attributeCode: "A", + drawingId: "SZ0230K01AV0", + installMethod: "拆装", + executeStandard: "", + manufacturer: "", + manufacturerCode: "", + tagMaterial: "", + model3dFileName: "", + model3dDataUrl: "", + model3dNodeOffsets: {}, + model3dNodeRotations: {}, + ...overrides, + }; +} + describe("NonIntegratedDetailClient", () => { let anchorClickSpy: jest.SpyInstance; @@ -509,4 +545,207 @@ describe("NonIntegratedDetailClient", () => { within(detailList).getByAltText("扶手细节图.png"), ).toBeInTheDocument(); }); + it("persists 泡沫护角 rows after submit and reload", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-package-corner-save", + spu: "SZ0230", + skuItems: [createSkuItem()], + bomDetails: [ + { + styleKey: "K01", + rows: [], + hiddenRowIds: [], + syntheticUnitPriceOverrides: {}, + }, + ], + }), + ); + + const { unmount } = render( + , + ); + + await actor.click(screen.getByRole("button", { name: "新增泡沫护角" })); + + const packageHeading = await screen.findByRole("heading", { + name: "泡沫护角", + }); + const packageSection = packageHeading.closest("div.overflow-hidden"); + expect(packageSection).not.toBeNull(); + + const rowFields = packageSection!.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >("tbody tr input, tbody tr textarea, tbody tr select"); + expect(rowFields.length).toBeGreaterThanOrEqual(9); + + await actor.clear(rowFields[0]!); + await actor.type(rowFields[0]!, "BM-HJ-001"); + await actor.clear(rowFields[1]!); + await actor.type(rowFields[1]!, "泡沫护角"); + await actor.clear(rowFields[3]!); + await actor.type(rowFields[3]!, "泡沫"); + await actor.clear(rowFields[4]!); + await actor.type(rowFields[4]!, "0.06"); + await actor.clear(rowFields[5]!); + await actor.type(rowFields[5]!, "4"); + await actor.clear(rowFields[7]!); + await actor.type(rowFields[7]!, "2"); + + await actor.click(screen.getByRole("button", { name: "提交完成" })); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "DNZ0001-package-corner-save", + ); + const sharedDetail = savedProduct?.bomDetails.find( + (detail) => detail.styleKey === "K01" && !detail.skuId, + ); + const cornerRow = sharedDetail?.rows.find( + (row) => row.packageSubcategory === "泡沫护角", + ); + + expect(cornerRow).toBeDefined(); + expect(cornerRow?.partCode).toBe("BM-HJ-001"); + expect(cornerRow?.partName).toBe("泡沫护角"); + expect(cornerRow?.material).toBe("泡沫"); + expect(cornerRow?.length).toBe("0.06"); + expect(cornerRow?.quantity).toBe("4"); + expect(cornerRow?.unitPrice).toBe("2"); + }); + + unmount(); + + render( + , + ); + + const reloadedHeading = await screen.findByRole("heading", { + name: "泡沫护角", + }); + const reloadedSection = reloadedHeading.closest("div.overflow-hidden"); + expect(reloadedSection).not.toBeNull(); + expect(within(reloadedSection!).getByText("当前 1 行")).toBeInTheDocument(); + + const reloadedFields = reloadedSection!.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >("tbody tr input, tbody tr textarea, tbody tr select"); + expect(reloadedFields[0]).toHaveValue("BM-HJ-001"); + expect(reloadedFields[1]).toHaveValue("泡沫护角"); + expect(reloadedFields[3]).toHaveValue("泡沫"); + expect(reloadedFields[4]).toHaveValue("0.06"); + expect(reloadedFields[5]).toHaveValue("4"); + expect(reloadedFields[7]).toHaveValue("2"); + }); + it("keeps sku override rows isolated when adding package corner shared rows", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-package-corner-with-override", + spu: "SZ0230", + skuItems: [createSkuItem()], + bomDetails: [ + { + styleKey: "K01", + rows: [], + hiddenRowIds: [], + syntheticUnitPriceOverrides: {}, + }, + { + styleKey: "K01", + skuId: "sku-test-1", + rows: [ + { + id: "override-steel-row-1", + category: "钢架", + partCode: "GJ-001", + partImage: "", + partName: "桌腿", + structure: "承重", + material: "方管 20×20×0.6", + process: "", + remark: "", + specSize: "", + length: "700", + width: "", + longEdgeCount: "", + shortEdgeCount: "", + unit: "米", + factor: "1", + unitPrice: "1.16", + unitWeight: "0.38", + quantity: "1", + }, + ], + hiddenRowIds: [], + syntheticUnitPriceOverrides: {}, + }, + ], + }), + ); + + render( + , + ); + + await actor.click(screen.getByRole("button", { name: "新增泡沫护角" })); + + const packageHeading = await screen.findByRole("heading", { + name: "泡沫护角", + }); + const packageSection = packageHeading.closest("div.overflow-hidden"); + expect(packageSection).not.toBeNull(); + + const rowFields = packageSection!.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >("tbody tr input, tbody tr textarea, tbody tr select"); + expect(rowFields.length).toBeGreaterThanOrEqual(9); + + await actor.clear(rowFields[1]!); + await actor.type(rowFields[1]!, "泡沫护角"); + await actor.clear(rowFields[3]!); + await actor.type(rowFields[3]!, "泡沫"); + await actor.clear(rowFields[4]!); + await actor.type(rowFields[4]!, "0.06"); + await actor.clear(rowFields[5]!); + await actor.type(rowFields[5]!, "4"); + await actor.clear(rowFields[7]!); + await actor.type(rowFields[7]!, "2"); + + await actor.click(screen.getByRole("button", { name: "提交完成" })); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "DNZ0001-package-corner-with-override", + ); + const sharedDetail = savedProduct?.bomDetails.find( + (detail) => detail.styleKey === "K01" && !detail.skuId, + ); + const overrideDetail = savedProduct?.bomDetails.find( + (detail) => detail.styleKey === "K01" && detail.skuId === "sku-test-1", + ); + + expect( + sharedDetail?.rows.some((row) => row.id === "override-steel-row-1"), + ).toBe(false); + expect( + overrideDetail?.rows.some((row) => row.id === "override-steel-row-1"), + ).toBe(true); + expect( + sharedDetail?.rows.some((row) => row.packageSubcategory === "泡沫护角"), + ).toBe(true); + }); + }); }); 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 64e0258f..cc4d9990 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -118,6 +118,7 @@ import { type NonIntegratedStyleDetail, type NonIntegratedUploadFile, } from "./nonIntegratedStorage"; +import { saveSharedNonIntegratedProduct } from "./nonIntegratedApi"; type BasicFieldType = "text" | "textarea" | "select"; type SkuTabKey = "skuInfo" | "fileUpload"; @@ -4260,9 +4261,11 @@ function buildProductPayload( export default function NonIntegratedDetailClient({ mode, productId, + initialProduct = null, }: { mode: "create" | "edit" | "view"; productId?: string; + initialProduct?: NonIntegratedProductRecord | null; }) { const router = useRouter(); const previewFileInputRef = useRef(null); @@ -4285,7 +4288,9 @@ export default function NonIntegratedDetailClient({ const model3dInputRef = useRef(null); const existingProduct = mode !== "create" && productId - ? getNonIntegratedProductById(productId) + ? shouldUseRemoteAssetStorage() + ? initialProduct + : getNonIntegratedProductById(productId) : null; const initialBomDetailsResultRef = useRef(null); if (!initialBomDetailsResultRef.current) { @@ -5816,7 +5821,11 @@ export default function NonIntegratedDetailClient({ ), ); - saveNonIntegratedProduct(nextProduct); + if (shouldUseRemoteAssetStorage()) { + await saveSharedNonIntegratedProduct(nextProduct); + } else { + saveNonIntegratedProduct(nextProduct); + } setSaveMessage(successMessage); setSaveMessageType("success"); return true; @@ -6616,37 +6625,6 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } - const updateCurrentBomRows = useCallback( - (updater: (rows: NonIntegratedBomRow[]) => NonIntegratedBomRow[]) => { - if (isReadOnly || !selectedBomStyleKey) return; - - setBomDetails((current) => { - const sharedDetail = - current[selectedBomStyleKey] ?? createEmptyBomDetailState(); - const sharedRows = sharedDetail.rows; - const currentSkuRows = - selectedBomSkuKey && hasBomDetailOverride(current, selectedBomSkuKey) - ? (current[selectedBomSkuKey]?.rows ?? []) - : []; - const baseRows = - currentSkuRows.length > 0 - ? mergeBomRows(sharedRows, currentSkuRows) - : sharedRows; - const nextRows = updater(cloneBomRows(baseRows)); - return { - ...current, - [selectedBomStyleKey]: { - ...sharedDetail, - rows: cloneBomRows(nextRows), - }, - }; - }); - setSaveMessage(""); - setSaveMessageType("success"); - }, - [isReadOnly, selectedBomSkuKey, selectedBomStyleKey], - ); - function resolveNextBomRow( row: NonIntegratedBomRow, key: BomRowFieldKey, @@ -6687,8 +6665,23 @@ export default function NonIntegratedDetailClient({ let appendedRowId = ""; - updateCurrentBomRows((current) => { - const scopedRows = current.filter( + setBomDetails((current) => { + const sharedDetail = + current[selectedBomStyleKey] ?? createEmptyBomDetailState(); + const sharedRows = sharedDetail.rows; + const currentOverrideRows = + selectedBomSkuKey && hasBomDetailOverride(current, selectedBomSkuKey) + ? (current[selectedBomSkuKey]?.rows ?? []) + : []; + const currentHiddenRowIds = + selectedBomSkuKey && hasBomDetailOverride(current, selectedBomSkuKey) + ? (current[selectedBomSkuKey]?.hiddenRowIds ?? []) + : []; + const visibleRows = + currentOverrideRows.length > 0 || currentHiddenRowIds.length > 0 + ? mergeBomRows(sharedRows, currentOverrideRows, currentHiddenRowIds) + : cloneBomRows(sharedRows); + const scopedVisibleRows = visibleRows.filter( (row) => getBomRowCategory(row) === category && (category !== "包材" || @@ -6696,18 +6689,29 @@ export default function NonIntegratedDetailClient({ getBomRowPackageSubcategory(row) === packageSubcategory), ); const previousRow = - scopedRows.length > 0 ? scopedRows[scopedRows.length - 1] : null; + scopedVisibleRows.length > 0 + ? scopedVisibleRows[scopedVisibleRows.length - 1] + : null; const nextRow = applyBomRowLibraryMetrics( createNextBomRowFromPrevious(previousRow, category, packageSubcategory), ); appendedRowId = nextRow.id; - return [...current, nextRow]; + + return { + ...current, + [selectedBomStyleKey]: { + ...sharedDetail, + rows: [...sharedRows, nextRow], + }, + }; }); setActiveSharedBomRowIds((current) => appendedRowId ? Array.from(new Set([...current, appendedRowId])) : current, ); + setSaveMessage(""); + setSaveMessageType("success"); } function updateBomRow(rowId: string, key: BomRowFieldKey, value: string) { @@ -8451,7 +8455,9 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations, ), ); - const savedProduct = saveNonIntegratedProduct(normalizedProduct); + const savedProduct = shouldUseRemoteAssetStorage() + ? await saveSharedNonIntegratedProduct(normalizedProduct) + : saveNonIntegratedProduct(normalizedProduct); router.push( `/intake/non-integrated/${encodeURIComponent(savedProduct.id)}`, ); diff --git a/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx b/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx index aa650bc8..2ed85cc1 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx @@ -1,4 +1,5 @@ import { requireSession } from '@/lib/server-session' +import { intakeNonIntegratedProductService } from '@/server/services' import NonIntegratedDetailClient from '../NonIntegratedDetailClient' export default async function NonIntegratedDetailPage({ @@ -8,10 +9,18 @@ export default async function NonIntegratedDetailPage({ params: Promise<{ id: string }> searchParams: Promise<{ mode?: string }> }) { - await requireSession('/login') + const { authUser } = await requireSession('/login') const { id } = await params const resolvedSearchParams = await searchParams const mode = resolvedSearchParams.mode === 'view' ? 'view' : 'edit' + const decodedId = decodeURIComponent(id) + const initialProduct = await intakeNonIntegratedProductService.getById(decodedId, authUser) - return + return ( + + ) } diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts index e5c80eb4..08841e8c 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts @@ -28,6 +28,16 @@ export async function listSharedNonIntegratedProducts() { return data.products } +export async function getSharedNonIntegratedProductById(id: string) { + const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(id)}`, { + method: 'GET', + cache: 'no-store', + }) + + const data = await readApiData<{ product: NonIntegratedProductRecord }>(response) + return data.product +} + export async function saveSharedNonIntegratedProduct(product: NonIntegratedProductRecord) { const response = await fetch('/api/intake/non-integrated', { method: 'POST', diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 9cd084b6..04b8ae67 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -4,6 +4,7 @@ import { categoryService, draftGroupService, intakeService, + intakeNonIntegratedProductService, materialService, warehouseService, } from '@/server/services' @@ -45,7 +46,7 @@ export default async function IntakePage({ }: { searchParams: Promise> }) { - const { user } = await requireSession('/login') + const { user, authUser } = await requireSession('/login') const params = await searchParams const draftGroupId = typeof params.draftGroupId === 'string' ? params.draftGroupId : null @@ -56,6 +57,7 @@ export default async function IntakePage({ materialLibraryFinishes, materialLibraryCategories, materialLibraryDictionaries, + initialNonIntegratedProducts, ] = await Promise.all([ draftGroupService.list(), warehouseService.listLocations(), @@ -63,6 +65,7 @@ export default async function IntakePage({ materialService.findAll({ limit: 500 }), materialService.findAllCategories(), assetCenterService.getSubstrateDictionaryBundle(), + intakeNonIntegratedProductService.list(authUser), ]) const eligibleDraftGroups = draftGroups.filter((group) => @@ -128,6 +131,7 @@ export default async function IntakePage({ materialLibraryFinishes={materialLibraryFinishes} materialLibraryCategories={materialLibraryCategories} materialLibraryDictionaries={materialLibraryDictionaries} + initialNonIntegratedProducts={initialNonIntegratedProducts} /> ) } @@ -145,6 +149,7 @@ export default async function IntakePage({ materialLibraryFinishes={materialLibraryFinishes} materialLibraryCategories={materialLibraryCategories} materialLibraryDictionaries={materialLibraryDictionaries} + initialNonIntegratedProducts={initialNonIntegratedProducts} /> ) }