From 51b1341c5c48e182f43a5b6cf8f5b532c4d7d6c6 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 27 May 2026 08:41:10 +0800 Subject: [PATCH 1/8] fix: improve non-integrated detail saves --- .../NonIntegratedDetailClient.test.tsx | 116 +++++++ .../NonIntegratedDetailClient.tsx | 320 ++++++++++++++---- ...-non-integrated-product.repository.test.ts | 83 +++++ ...ntake-non-integrated-product.repository.ts | 15 +- 4 files changed, 470 insertions(+), 64 deletions(-) create mode 100644 furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts 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 13c2f65c..e1067a11 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 @@ -2034,6 +2034,66 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("商品企划")).toHaveValue("企划B"); }); + it("saves current detail immediately with Ctrl+S and shows the autosave toast", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "SZ0230-ctrl-s-save", + spu: "KP-SZ0230", + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-开品参考")); + await actor.clear(screen.getByLabelText("开品理由")); + await actor.type(screen.getByLabelText("开品理由"), "Ctrl+S保存内容"); + fireEvent.keyDown(window, { key: "s", code: "KeyS", ctrlKey: true }); + + expect(await screen.findByText("正在保存当前内容...")).toBeInTheDocument(); + expect(await screen.findByText("已保存当前内容")).toBeInTheDocument(); + await waitFor(() => { + const savedProduct = getNonIntegratedProductById("SZ0230-ctrl-s-save"); + expect(savedProduct?.openingReference.reason).toBe("Ctrl+S保存内容"); + }); + }); + + it("shows a clear Ctrl+S failure toast when current detail save fails", async () => { + const actor = userEvent.setup(); + const product = createProductRecord({ + id: "SZ0230-ctrl-s-save-failure", + spu: "KP-SZ0230", + previewImage: "", + previewImageName: "", + }); + + (globalThis as NonIntegratedRemoteAssetStorageTestGlobal) + .__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true; + mockedSaveSharedNonIntegratedProduct.mockRejectedValue( + new Error("服务暂时不可用"), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-开品参考")); + await actor.type(screen.getByLabelText("开品理由"), "失败时不应保存"); + fireEvent.keyDown(window, { key: "s", code: "KeyS", ctrlKey: true }); + + expect(await screen.findByText("正在保存当前内容...")).toBeInTheDocument(); + expect( + await screen.findByText("保存失败,当前内容未保存:服务暂时不可用"), + ).toBeInTheDocument(); + }); + it("shows progress while batch downloading selected files", async () => { const actor = userEvent.setup(); const productPhotoCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[7]; @@ -3934,6 +3994,62 @@ describe("NonIntegratedDetailClient", () => { ).not.toBeInTheDocument(); }); + it("flushes pending planning layout autosave when space visual users leave before debounce", async () => { + const actor = userEvent.setup(); + const planningLayoutCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[5]; + + saveNonIntegratedProduct( + createProductRecord({ + id: "STOCK001-space-layout-autosave-flush", + spu: "STOCK001", + processTemplate: "存量商品", + visualPlanning: "希文", + skuItems: [ + createSkuItem({ + id: "stock-space-layout-autosave-flush-sku-1", + model: "STOCKK01", + skuCode: "STOCK00101", + barcode: "STOCK00101V01", + specName: "存量SKU", + salesStatus: "销售", + }), + ], + }), + ); + + const { unmount } = render( + 品牌中心 > 空间视觉部", + ]} + />, + ); + + await actor.click( + screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`), + ); + fireEvent.change( + screen.getByTestId( + "planning-layout-priceBand-stock-space-layout-autosave-flush-sku-1", + ), + { target: { value: "700-800" } }, + ); + + unmount(); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "STOCK001-space-layout-autosave-flush", + ); + expect(savedProduct?.planningLayoutRows?.[0]?.priceBand).toBe("700-800"); + }); + }); + it("hides redundant fields from the product detail summary", async () => { const actor = userEvent.setup(); 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 4c36c0d0..3526c53b 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -5626,6 +5626,22 @@ type PlanningLayoutClipboardValue = values: string[]; }; +type AutoSaveNoticeType = "saving" | "success" | "error"; + +type PersistEditedProductOverrides = { + documentWorkspaceConfirmations?: NonIntegratedDocumentWorkspaceConfirmationMap; + documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap; + reviewRounds?: NonIntegratedReviewRound[]; + finishedSampleShipmentRows?: NonIntegratedFinishedSampleShipmentRow[]; + openingReference?: NonIntegratedOpeningReference; + skuItems?: NonIntegratedSkuItem[]; + skuBatchCount?: number; + skuBatchConfirmedAt?: Record; + projectStatus?: NonIntegratedProjectStatus; + planningLayoutRows?: NonIntegratedPlanningLayoutRow[]; + allowReadOnly?: boolean; +}; + type ProductLimitDisplayRow = NonIntegratedProductLimitRow & { image: string; spu: string; @@ -10269,6 +10285,7 @@ export default function NonIntegratedDetailClient({ const [planningLayoutRows, setPlanningLayoutRows] = useState< NonIntegratedPlanningLayoutRow[] >(existingProduct?.planningLayoutRows ?? []); + const planningLayoutRowsRef = useRef(planningLayoutRows); const [planningLayoutDraftInputs, setPlanningLayoutDraftInputs] = useState< Record >({}); @@ -10419,6 +10436,10 @@ export default function NonIntegratedDetailClient({ ); const [isAutoSaveNoticeVisible, setIsAutoSaveNoticeVisible] = useState(false); + const [autoSaveNoticeType, setAutoSaveNoticeType] = + useState("success"); + const [autoSaveNoticeMessage, setAutoSaveNoticeMessage] = + useState("已自动保存"); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [skuImagePreview, setSkuImagePreview] = useState<{ src: string; @@ -10991,13 +11012,19 @@ export default function NonIntegratedDetailClient({ selectedSupplementSkuId: string; } | null>(null); const autoSaveTimeoutRef = useRef(null); + const pendingAutoSaveOverridesRef = + useRef(null); const autoSaveNoticeTimeoutRef = useRef(null); + const lastPersistErrorMessageRef = useRef(""); const assetUploadCacheRef = useRef(new Map>()); const localStorageCompactionFinishedRef = useRef(false); const documentWorkspaceRevisionSignatureRef = useRef | null>(null); + useEffect(() => { + planningLayoutRowsRef.current = planningLayoutRows; + }, [planningLayoutRows]); const [supplierDirectoryOptions, setSupplierDirectoryOptions] = useState(SUPPLIER_DIRECTORY); const [warehouseDirectoryOptions, setWarehouseDirectoryOptions] = useState< @@ -14197,16 +14224,33 @@ export default function NonIntegratedDetailClient({ localStorageCompactionFinishedRef.current = true; }, [normalizeProductAssetsForStorage]); - const showAutoSaveNotice = useCallback(() => { - if (autoSaveNoticeTimeoutRef.current) { - window.clearTimeout(autoSaveNoticeTimeoutRef.current); - } - setIsAutoSaveNoticeVisible(true); - autoSaveNoticeTimeoutRef.current = window.setTimeout(() => { - setIsAutoSaveNoticeVisible(false); - autoSaveNoticeTimeoutRef.current = null; - }, 2000); - }, []); + const showAutoSaveNotice = useCallback( + ({ + message = "已自动保存", + type = "success", + durationMs = 2000, + }: { + message?: string; + type?: AutoSaveNoticeType; + durationMs?: number; + } = {}) => { + if (autoSaveNoticeTimeoutRef.current) { + window.clearTimeout(autoSaveNoticeTimeoutRef.current); + } + setAutoSaveNoticeType(type); + setAutoSaveNoticeMessage(message); + setIsAutoSaveNoticeVisible(true); + if (durationMs > 0) { + autoSaveNoticeTimeoutRef.current = window.setTimeout(() => { + setIsAutoSaveNoticeVisible(false); + autoSaveNoticeTimeoutRef.current = null; + }, durationMs); + } else { + autoSaveNoticeTimeoutRef.current = null; + } + }, + [], + ); const persistEditedProduct = useCallback( async ({ @@ -14216,18 +14260,7 @@ export default function NonIntegratedDetailClient({ }: { successMessage: string; errorFallbackMessage: string; - overrides?: { - documentWorkspaceConfirmations?: NonIntegratedDocumentWorkspaceConfirmationMap; - documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap; - reviewRounds?: NonIntegratedReviewRound[]; - finishedSampleShipmentRows?: NonIntegratedFinishedSampleShipmentRow[]; - openingReference?: NonIntegratedOpeningReference; - skuItems?: NonIntegratedSkuItem[]; - skuBatchCount?: number; - skuBatchConfirmedAt?: Record; - projectStatus?: NonIntegratedProjectStatus; - allowReadOnly?: boolean; - }; + overrides?: PersistEditedProductOverrides; }) => { const nextDocumentWorkspaceConfirmations = overrides?.documentWorkspaceConfirmations ?? @@ -14244,6 +14277,8 @@ export default function NonIntegratedDetailClient({ const nextSkuBatchConfirmedAt = overrides?.skuBatchConfirmedAt ?? skuBatchConfirmedAt; const nextProjectStatus = overrides?.projectStatus ?? projectStatus; + const nextPlanningLayoutRows = + overrides?.planningLayoutRows ?? planningLayoutRows; if ( mode !== "edit" || @@ -14272,7 +14307,7 @@ export default function NonIntegratedDetailClient({ qualityInspectionSheet, productFactoryRows, productPricingRows, - planningLayoutRows, + nextPlanningLayoutRows, productLimitRows, productSampleMeasurementRows, nextFinishedSampleShipmentRows, @@ -14313,13 +14348,15 @@ export default function NonIntegratedDetailClient({ } onProductSavedRef.current?.(savedProduct); clearNonIntegratedBomDraftSnapshot(productId); + lastPersistErrorMessageRef.current = ""; setSaveMessage(successMessage); setSaveMessageType("success"); return true; } catch (error) { - setSaveMessage( - error instanceof Error ? error.message : errorFallbackMessage, - ); + const errorMessage = + error instanceof Error ? error.message : errorFallbackMessage; + lastPersistErrorMessageRef.current = errorMessage; + setSaveMessage(errorMessage); setSaveMessageType("error"); return false; } @@ -14364,12 +14401,31 @@ export default function NonIntegratedDetailClient({ ], ); + const persistEditedProductRef = useRef(persistEditedProduct); + const showAutoSaveNoticeRef = useRef(showAutoSaveNotice); + useEffect(() => { - return () => { - if (autoSaveTimeoutRef.current) { - window.clearTimeout(autoSaveTimeoutRef.current); - autoSaveTimeoutRef.current = null; + persistEditedProductRef.current = persistEditedProduct; + showAutoSaveNoticeRef.current = showAutoSaveNotice; + }, [persistEditedProduct, showAutoSaveNotice]); + + useEffect(() => { + const handlePageHide = () => { + flushPendingAutoSave({ showNotice: false }); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushPendingAutoSave({ showNotice: false }); } + }; + + window.addEventListener("pagehide", handlePageHide); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("pagehide", handlePageHide); + document.removeEventListener("visibilitychange", handleVisibilityChange); + flushPendingAutoSave({ showNotice: false }); if (autoSaveNoticeTimeoutRef.current) { window.clearTimeout(autoSaveNoticeTimeoutRef.current); autoSaveNoticeTimeoutRef.current = null; @@ -14377,7 +14433,88 @@ export default function NonIntegratedDetailClient({ }; }, []); - function scheduleAutoSave() { + function flushPendingAutoSave({ + showNotice = true, + }: { + showNotice?: boolean; + } = {}) { + if (!autoSaveTimeoutRef.current) return; + + window.clearTimeout(autoSaveTimeoutRef.current); + autoSaveTimeoutRef.current = null; + const pendingOverrides = pendingAutoSaveOverridesRef.current ?? undefined; + pendingAutoSaveOverridesRef.current = null; + + void persistEditedProductRef.current({ + successMessage: "已自动保存最新修改", + errorFallbackMessage: "自动保存失败,请稍后再试", + overrides: pendingOverrides, + }).then((saved) => { + if (saved && showNotice) { + showAutoSaveNoticeRef.current(); + } + }); + } + + const saveCurrentDetailNow = useCallback(async () => { + if (mode !== "edit" || !productId || !existingProductCreatedAt) { + showAutoSaveNotice({ + message: "当前页面还不能保存", + type: "error", + durationMs: 3000, + }); + return; + } + + if (isReadOnly) { + showAutoSaveNotice({ + message: "当前账号没有编辑权限,不能保存", + type: "error", + durationMs: 3000, + }); + return; + } + + if (autoSaveTimeoutRef.current) { + window.clearTimeout(autoSaveTimeoutRef.current); + autoSaveTimeoutRef.current = null; + } + const pendingOverrides = pendingAutoSaveOverridesRef.current ?? undefined; + pendingAutoSaveOverridesRef.current = null; + + showAutoSaveNotice({ + message: "正在保存当前内容...", + type: "saving", + durationMs: 0, + }); + + const saved = await persistEditedProduct({ + successMessage: "已保存当前内容", + errorFallbackMessage: "保存失败,当前内容未保存,请稍后再试", + overrides: pendingOverrides, + }); + + showAutoSaveNotice({ + message: saved + ? "已保存当前内容" + : `保存失败,当前内容未保存${ + lastPersistErrorMessageRef.current + ? `:${lastPersistErrorMessageRef.current}` + : "" + }`, + type: saved ? "success" : "error", + durationMs: saved ? 2200 : 4000, + }); + }, [ + existingProductCreatedAt, + isReadOnly, + mode, + persistEditedProduct, + productId, + showAutoSaveNotice, + ]); + + function scheduleAutoSave(overrides?: PersistEditedProductOverrides) { if ( mode !== "edit" || !productId || @@ -14387,14 +14524,24 @@ export default function NonIntegratedDetailClient({ return; } + if (overrides) { + pendingAutoSaveOverridesRef.current = { + ...(pendingAutoSaveOverridesRef.current ?? {}), + ...overrides, + }; + } + if (autoSaveTimeoutRef.current) { window.clearTimeout(autoSaveTimeoutRef.current); } autoSaveTimeoutRef.current = window.setTimeout(() => { + const pendingOverrides = pendingAutoSaveOverridesRef.current ?? undefined; + pendingAutoSaveOverridesRef.current = null; void persistEditedProduct({ successMessage: "已自动保存最新修改", errorFallbackMessage: "自动保存失败,请稍后再试", + overrides: pendingOverrides, }).then((saved) => { if (saved) { showAutoSaveNotice(); @@ -14404,6 +14551,30 @@ export default function NonIntegratedDetailClient({ }, AUTO_SAVE_DELAY_MS); } + useEffect(() => { + function handleManualSaveShortcut(event: KeyboardEvent) { + const hasCommandKey = event.ctrlKey || event.metaKey; + const key = event.key?.toLowerCase() ?? ""; + const code = event.code?.toLowerCase() ?? ""; + if ( + !hasCommandKey || + event.altKey || + event.shiftKey || + (key !== "s" && code !== "keys") + ) { + return; + } + + event.preventDefault(); + void saveCurrentDetailNow(); + } + + window.addEventListener("keydown", handleManualSaveShortcut, true); + return () => { + window.removeEventListener("keydown", handleManualSaveShortcut, true); + }; + }, [saveCurrentDetailNow]); + useLayoutEffect(() => { if (mode !== "edit" || !productId || !existingProductUpdatedAt) return; @@ -16494,24 +16665,28 @@ export default function NonIntegratedDetailClient({ ) { if (isReadOnly) return; - setPlanningLayoutRows((current) => { - const rowBySkuId = new Map(current.map((row) => [row.skuId, row])); - const currentRow = - rowBySkuId.get(skuId) ?? createEmptyPlanningLayoutRow(skuId); - const nextRow = - typeof updater === "function" - ? updater(currentRow) - : { - ...currentRow, - ...updater, - }; + const rowBySkuId = new Map( + planningLayoutRowsRef.current.map((row) => [row.skuId, row]), + ); + const currentRow = + rowBySkuId.get(skuId) ?? createEmptyPlanningLayoutRow(skuId); + const nextRow = + typeof updater === "function" + ? updater(currentRow) + : { + ...currentRow, + ...updater, + }; - rowBySkuId.set(skuId, nextRow); + rowBySkuId.set(skuId, nextRow); - return derivedSkuItems - .map((sku) => rowBySkuId.get(sku.id)) - .filter((row): row is NonIntegratedPlanningLayoutRow => Boolean(row)); - }); + const nextRows = derivedSkuItems + .map((sku) => rowBySkuId.get(sku.id)) + .filter((row): row is NonIntegratedPlanningLayoutRow => Boolean(row)); + + planningLayoutRowsRef.current = nextRows; + setPlanningLayoutRows(nextRows); + scheduleAutoSave({ planningLayoutRows: nextRows }); setSaveMessage(""); setSaveMessageType("success"); } @@ -17781,6 +17956,7 @@ export default function NonIntegratedDetailClient({ } function handleOpenDocumentWorkspace(category: DocumentWorkspaceCategory) { + flushPendingAutoSave(); const nextCategory = category; if (!isDocumentWorkspaceCategoryVisibleForCurrentUser(nextCategory)) { @@ -21098,6 +21274,7 @@ export default function NonIntegratedDetailClient({ window.clearTimeout(autoSaveTimeoutRef.current); autoSaveTimeoutRef.current = null; } + pendingAutoSaveOverridesRef.current = null; setIsSubmitting(true); try { @@ -31814,21 +31991,40 @@ function renderFactoryQualityWorkspace() { ) : null} {isAutoSaveNoticeVisible ? ( -
-
- - ✓ - - - 已自动保存 - -
-
+ (() => { + const noticeClassName = + autoSaveNoticeType === "error" + ? "bg-apple-red" + : autoSaveNoticeType === "saving" + ? "bg-apple-blue" + : "bg-apple-green"; + const noticeIcon = + autoSaveNoticeType === "error" + ? "!" + : autoSaveNoticeType === "saving" + ? "…" + : "✓"; + + return ( +
+
+ + {noticeIcon} + + + {autoSaveNoticeMessage} + +
+
+ ); + })() ) : null} {uploadProgress ? (
({ + prisma: { + intakeNonIntegratedProduct: { + findMany: jest.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { IntakeNonIntegratedProductRepositoryClass } from '../intake-non-integrated-product.repository' + +const mockPrisma = prisma as unknown as { + intakeNonIntegratedProduct: { + findMany: jest.Mock + } +} + +describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('reuses soft-deleted records when old brand names match normalized brands', async () => { + const repository = new IntakeNonIntegratedProductRepositoryClass() + const deletedRecord = { + id: 'deleted-gujia', + spu: 'GBGJ-SZ0230', + brand: '顾家', + deletedAt: new Date('2026-05-19T01:52:56.892Z'), + } + mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([ + deletedRecord, + ]) + + const result = await repository.findDeletedBySpu( + 'GBGJ-SZ0230', + 'GBGJ-SZ0230-1779442274541', + '代理品牌-顾家', + ) + + expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith({ + where: { + spu: 'GBGJ-SZ0230', + deletedAt: { + not: null, + }, + id: { + not: 'GBGJ-SZ0230-1779442274541', + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }) + expect(result).toBe(deletedRecord) + }) + + it('does not reuse an unrelated soft-deleted brand when several deleted records exist', async () => { + const repository = new IntakeNonIntegratedProductRepositoryClass() + mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([ + { + id: 'deleted-sofiya', + spu: 'GBGJ-SZ0230', + brand: '索菲亚', + deletedAt: new Date('2026-05-19T01:52:56.892Z'), + }, + { + id: 'deleted-pumei', + spu: 'GBGJ-SZ0230', + brand: '朴美', + deletedAt: new Date('2026-05-19T01:52:56.892Z'), + }, + ]) + + const result = await repository.findDeletedBySpu( + 'GBGJ-SZ0230', + undefined, + '代理品牌-顾家', + ) + + expect(result).toBeNull() + }) +}) diff --git a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts index 3676244c..8735c20d 100644 --- a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts +++ b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts @@ -1,4 +1,5 @@ import { IntakeNonIntegratedProduct, Prisma } from '@prisma/client' +import { normalizeNonIntegratedBrand } from '@/app/intake/non-integrated/nonIntegratedStorage' import { BaseRepository } from './base.repository' export type IntakeNonIntegratedProductCreateInput = Prisma.IntakeNonIntegratedProductCreateInput @@ -88,10 +89,9 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< } async findDeletedBySpu(spu: string, excludeId?: string, brand?: string) { - return this.prisma.intakeNonIntegratedProduct.findFirst({ + const deletedRecords = await this.prisma.intakeNonIntegratedProduct.findMany({ where: { spu, - ...(brand ? { brand } : null), deletedAt: { not: null, }, @@ -107,6 +107,17 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< updatedAt: 'desc', }, }) + + if (!brand) { + return deletedRecords[0] ?? null + } + + const normalizedBrand = normalizeNonIntegratedBrand(brand) + return ( + deletedRecords.find( + (record) => normalizeNonIntegratedBrand(record.brand) === normalizedBrand, + ) ?? null + ) } async softDeleteByUser(id: string, updatedBy: string) { From 1b0141908e66580c94c9fdbb15d5cc5a61764bbb Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 27 May 2026 09:27:55 +0800 Subject: [PATCH 2/8] fix(intake): align space visual planning role --- .../src/lib/business-role-inference.test.ts | 12 ++++++++++++ .../src/lib/business-role-inference.ts | 4 ++-- .../src/lib/non-integrated-permissions.test.ts | 15 +++++++++++++++ .../src/lib/non-integrated-permissions.ts | 7 ++----- 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 furniture-3d-system/src/lib/business-role-inference.test.ts diff --git a/furniture-3d-system/src/lib/business-role-inference.test.ts b/furniture-3d-system/src/lib/business-role-inference.test.ts new file mode 100644 index 00000000..3776c66f --- /dev/null +++ b/furniture-3d-system/src/lib/business-role-inference.test.ts @@ -0,0 +1,12 @@ +import { inferBusinessRolesFromDepartmentPath } from './business-role-inference' + +describe('business role inference', () => { + it('maps brand center space visual department to planning', () => { + const result = inferBusinessRolesFromDepartmentPath( + '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + ) + + expect(result.businessRoles).toEqual(['PLANNING']) + expect(result.reason).toContain('企划') + }) +}) diff --git a/furniture-3d-system/src/lib/business-role-inference.ts b/furniture-3d-system/src/lib/business-role-inference.ts index 1a99ebbb..a1ca6bfb 100644 --- a/furniture-3d-system/src/lib/business-role-inference.ts +++ b/furniture-3d-system/src/lib/business-role-inference.ts @@ -170,10 +170,10 @@ export function inferBusinessRolesFromDepartmentPath( if (root === '品牌中心' && includesSegment(parts, /空间视觉部/)) { return { - businessRoles: ['DESIGN'], + businessRoles: ['PLANNING'], excluded: false, shouldWarnWhenEmpty: true, - reason: '品牌中心空间视觉部默认归设计', + reason: '品牌中心空间视觉部默认归企划', } } diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts index d8a11a82..d9fb5038 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts @@ -165,6 +165,21 @@ describe('non-integrated intake permissions', () => { expect(canEditNonIntegratedWorkspace(baseProduct, user, '设计卖点')).toBe(false) }) + it('keeps planning business role from opening planning layout without space visual department', () => { + const user = { + id: 'planning-role-only-id', + name: '企划B', + role: 'USER', + businessRoles: ['PLANNING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 商品企划部 > 一线品牌', + ], + } + + expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(false) + expect(canEditNonIntegratedWorkspace(baseProduct, user, '企划布局')).toBe(false) + }) + it('separates purchaser owner, pricing group, and quality group access', () => { const purchaser = { id: 'purchase-id', diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.ts b/furniture-3d-system/src/lib/non-integrated-permissions.ts index 2e5e764f..e31a9285 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.ts @@ -181,7 +181,7 @@ function isBrandCenterDepartmentPath(path: string) { } function isSpaceVisualDepartmentPath(path: string) { - return /空间视觉部/.test(path) + return /品牌中心/.test(path) && /空间视觉部/.test(path) } function isSupplyChainPurchaseDepartmentPath(path: string) { @@ -267,10 +267,7 @@ export function isIntakeNonIntegratedBrandCenterPlanningUser( export function isIntakeNonIntegratedSpaceDepartmentUser( user: IntakeNonIntegratedPermissionUser, ) { - return ( - hasDepartmentPath(user, isSpaceVisualDepartmentPath) || - hasBusinessRoleFallback(user, 'DESIGN') - ) + return hasDepartmentPath(user, isSpaceVisualDepartmentPath) } export function isIntakeNonIntegratedSupplyChainUser( From 2a1058ec7762566de5f455c1af100a7111c1c347 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 27 May 2026 10:54:49 +0800 Subject: [PATCH 3/8] fix(intake): allow space visual department layout saves --- .../intake-node-permission-settings.test.ts | 12 ++ .../lib/intake-node-permission-settings.ts | 2 +- .../lib/non-integrated-permissions.test.ts | 17 +++ .../src/lib/non-integrated-permissions.ts | 2 +- ...ake-non-integrated-product.service.test.ts | 115 ++++++++++++++++++ ...ntake-non-integrated-scope.service.test.ts | 38 ++++++ 6 files changed, 184 insertions(+), 2 deletions(-) diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts index ac6a20eb..cf863336 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts @@ -16,6 +16,18 @@ describe('intake node permission settings', () => { }), ).toEqual(['brandCenter', 'space']) + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['PLANNING'], + department: { + name: '空间视觉部', + fullPath: null, + }, + departmentPaths: [], + }), + ).toEqual(['space']) + expect( resolveIntakeNodePermissionSubjectKeys({ role: 'USER', diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.ts index a0437207..59ec6ef5 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.ts @@ -615,7 +615,7 @@ function isBrandPlanningDepartmentPath(path: string) { } function isSpaceVisualDepartmentPath(path: string) { - return /品牌中心/.test(path) && /空间视觉部/.test(path); + return /空间视觉部/.test(path) && (!/>/.test(path) || /品牌中心/.test(path)); } function isBrandCenterDepartmentPath(path: string) { diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts index d9fb5038..8595bd62 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts @@ -165,6 +165,23 @@ describe('non-integrated intake permissions', () => { expect(canEditNonIntegratedWorkspace(baseProduct, user, '设计卖点')).toBe(false) }) + it('allows space visual users when the auth context only carries department name', () => { + const user = { + id: 'space-name-only-id', + name: '希文', + role: 'USER', + businessRoles: ['PLANNING'], + department: { + name: '空间视觉部', + fullPath: null, + }, + departmentPaths: [], + } + + expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, user, '产品定厂')).toBe(false) + }) + it('keeps planning business role from opening planning layout without space visual department', () => { const user = { id: 'planning-role-only-id', diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.ts b/furniture-3d-system/src/lib/non-integrated-permissions.ts index e31a9285..2f2b527e 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.ts @@ -181,7 +181,7 @@ function isBrandCenterDepartmentPath(path: string) { } function isSpaceVisualDepartmentPath(path: string) { - return /品牌中心/.test(path) && /空间视觉部/.test(path) + return /空间视觉部/.test(path) && (!/>/.test(path) || /品牌中心/.test(path)) } function isSupplyChainPurchaseDepartmentPath(path: string) { diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index c7bf7e69..42290c30 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -14,6 +14,12 @@ jest.mock('../../repositories/intake-non-integrated-product.repository', () => ( }, })) +jest.mock('../intake-node-permission-settings.service', () => ({ + intakeNodePermissionSettingsService: { + getSettings: jest.fn().mockResolvedValue({ overrides: {} }), + }, +})) + import { intakeNonIntegratedProductRepository } from '../../repositories/intake-non-integrated-product.repository' import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service' @@ -493,6 +499,115 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).toHaveBeenCalled() }) + it('allows space visual department users to save planning layout through the shared save API', async () => { + const skuItems = [ + { + id: 'sku-1', + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'K01', + skuCode: 'DNZ000101', + setCount: '1', + specName: '款式一', + barcode: 'DNZ000101N1280V01', + size: '', + color: '', + material: '', + attributeCode: 'A', + drawingId: 'DNZ0001K01AV01', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + model3dNodeOffsets: {}, + model3dNodeRotations: {}, + }, + ] + const productLimitRows = [ + { + skuId: 'sku-1', + expectedDailyLimitPrice: '599', + expectedLimitGrossMarginRate: '', + nonSpuOwnerLimitPrice: '', + }, + ] + const existingPayload = createPayload({ + skuItems, + productLimitRows, + planningLayoutRows: [], + }) + const incomingPayload = createPayload({ + ...existingPayload, + skuItems, + productLimitRows, + planningLayoutRows: [ + { + skuId: 'sku-1', + style: '', + styles: ['现代'], + styleTone: '', + styleTones: ['简约'], + audience: '', + audiences: ['年轻家庭'], + spaces: ['客厅'], + categories: [], + model: '', + priceBand: '500-600', + brands: [], + shop: '', + }, + ], + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + payload: existingPayload, + }) as never, + ) + mockedRepository.update.mockResolvedValue( + createRecord({ + payload: incomingPayload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + payload: incomingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'space-visual-1', + email: 'space@example.com', + name: '希文', + businessRoles: ['PLANNING'] as const, + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '', + }, + departmentPaths: [], + }), + ).resolves.toMatchObject({ + planningLayoutRows: [ + expect.objectContaining({ + skuId: 'sku-1', + priceBand: '500-600', + }), + ], + }) + + expect(mockedRepository.update).toHaveBeenCalled() + }) + it('keeps existing BOM details when an autosave submits an empty BOM payload', async () => { const existingBomDetails = [ { diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts index ce9810e7..28364a94 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts @@ -56,4 +56,42 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '产品定厂'), ).resolves.toBe(false) }) + + it('allows space layout writes when only the space visual department name is available', async () => { + const record = { + id: 'stock-space-record', + createdBy: 'designer-id', + designer: '设计同事', + purchaser: '采购同事', + visibility: 'DEPARTMENT', + departmentId: null, + teamId: null, + payload: { + productPlanning: '商品企划同事', + visualPlanning: '其他空间同事', + visualDirector: '其他空间同事', + }, + } + const spaceUser: AuthUser = { + id: 'xiweng-user-id', + email: 'xiweng@example.local', + name: '希文', + role: 'USER', + status: 'ACTIVE', + businessRoles: ['PLANNING'], + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '', + }, + departmentPaths: [], + } + + await expect( + service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '空间布局'), + ).resolves.toBe(true) + await expect( + service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '产品定厂'), + ).resolves.toBe(false) + }) }) From 90432bb243dd8b7ac3d328d453f2abaece56be6d Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 27 May 2026 15:44:52 +0800 Subject: [PATCH 4/8] feat(intake): refine accessory library and bom exports --- .../src/app/intake/AccessoryLibraryPanel.tsx | 416 +++++++++- .../app/intake/AlphabetLibraryPanel.test.tsx | 123 ++- .../app/intake/EdgeBandingLibraryPanel.tsx | 178 +++- .../src/app/intake/SteelFrameLibraryPanel.tsx | 419 ++++++---- .../NonIntegratedDetailClient.tsx | 300 +++++-- .../src/lib/non-integrated-bom-export.test.ts | 158 +++- .../src/lib/non-integrated-bom-export.ts | 771 +++++++++++++++++- 7 files changed, 2035 insertions(+), 330 deletions(-) diff --git a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx index 7aa70fa5..1b2d372b 100644 --- a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx @@ -76,6 +76,7 @@ const LETTER_CATEGORY_LABELS: Partial> = A: '自攻螺丝', B: '平头螺丝', C: '连接螺杆', + D: '偏心连接件', } const A_HEAD_TYPES: HeadType[] = [ @@ -137,6 +138,20 @@ const HEAD_TYPES_BY_LETTER: Partial> const A_SCREW_CATEGORIES = ['自攻螺丝', '过山丝'] const B_SCREW_CATEGORIES = ['平头螺丝', '内六角螺丝', '拉手螺丝', '杯头螺丝'] const C_SCREW_CATEGORIES = ['全牙螺杆', '双头螺杆', '双头牙'] +const D_SCREW_CATEGORIES = [ + '三合一连接件', + '二合一连接件', + '四合一连接件', + '隐形连接件', + '双头偏心连接杆', +] + +const CATEGORY_SHORTCUTS_BY_LETTER: Partial< + Record +> = { + C: C_SCREW_CATEGORIES, + D: D_SCREW_CATEGORIES, +} const SELECT_FIELDS = [ { @@ -385,10 +400,66 @@ const C_SELECT_FIELD_OVERRIDES: Partial< }, } +const D_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: D_SCREW_CATEGORIES, + }, + material: { + label: '材质/表面处理', + options: [ + '锌合金', + '铁', + '碳钢', + '碳钢+锌合金', + '锌合金+碳钢', + '铁+尼龙', + '锌合金+塑料', + '塑料', + '尼龙', + '镀锌', + '镀铬', + '白锌', + '黑色', + '枪黑', + '本色', + ], + }, + diameter: { + label: '规格', + options: [ + 'M6', + 'M8', + 'φ12 M6', + 'φ15 M6', + 'φ15 M8', + 'φ18 M8', + ], + }, + length: { + label: '长度', + options: [ + '25mm', + '30mm', + '34mm', + '35mm', + '40mm', + '42mm', + '44mm', + '45mm', + '50mm', + '60mm', + '80mm', + ], + }, +} + const HIDDEN_SELECT_FIELD_KEYS_BY_LETTER: Partial< Record > = { C: ['drive'], + D: ['drive', 'tail', 'finish', 'tooth', 'color', 'preDrill'], } const EMPTY_FILTERS: ScrewFilterState = { @@ -458,12 +529,46 @@ const TABLE_COLUMNS = [ '操作', ] +const TABLE_COLUMN_FIELD_KEYS: Partial< + Record<(typeof TABLE_COLUMNS)[number], ScrewFilterKey | 'headType'> +> = { + 分类: 'screwCategory', + 头型: 'headType', + 槽型: 'drive', + 尾型: 'tail', + 材质: 'material', + 表面处理: 'finish', + 牙型: 'tooth', + 规格: 'diameter', + 长度: 'length', + 颜色: 'color', + 是否预开孔: 'preDrill', +} + +function getHiddenFieldKeys( + letter: AccessoryLibraryLetter, + shouldShowHeadType: boolean, +) { + const hiddenKeys = new Set( + HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? [], + ) + if (!shouldShowHeadType) { + hiddenKeys.add('headType') + } + return hiddenKeys +} + function getTableColumns(letter: AccessoryLibraryLetter, shouldShowHeadType: boolean) { + const hiddenFieldKeys = getHiddenFieldKeys(letter, shouldShowHeadType) return TABLE_COLUMNS.filter((column) => { - if (!shouldShowHeadType && column === '头型') return false - if (letter === 'C' && column === '槽型') return false + const fieldKey = TABLE_COLUMN_FIELD_KEYS[column] + if (fieldKey && hiddenFieldKeys.has(fieldKey)) return false return true - }).map((column) => (letter === 'C' && column === '长度' ? '尺寸' : column)) + }).map((column) => { + if (letter === 'C' && column === '长度') return '尺寸' + if (letter === 'D' && column === '材质') return '材质/表面处理' + return column + }) } const SCREW_DUPLICATE_KEYS = [ @@ -544,6 +649,21 @@ const DEFAULT_SCREW_FIELDS_BY_LETTER: Partial< preDrill: '需预开孔', environments: ['室内', '防锈'], }, + D: { + screwCategory: '三合一连接件', + headType: '', + drive: '', + tail: '', + material: '锌合金', + finish: '', + tooth: '', + diameter: 'φ15 M6', + length: '34mm', + usages: ['板式家具', '五金件', '柜体'], + color: '', + preDrill: '按配套孔位安装', + environments: ['室内'], + }, } const PRIMARY_BUTTON_CLASS = @@ -587,12 +707,18 @@ function getHeadTypes(letter: AccessoryLibraryLetter) { return HEAD_TYPES_BY_LETTER[letter] ?? [] } +function getCategoryShortcuts(letter: AccessoryLibraryLetter) { + return CATEGORY_SHORTCUTS_BY_LETTER[letter] ?? [] +} + function getSelectFields(letter: AccessoryLibraryLetter) { const overrides = letter === 'B' ? B_SELECT_FIELD_OVERRIDES : letter === 'C' ? C_SELECT_FIELD_OVERRIDES + : letter === 'D' + ? D_SELECT_FIELD_OVERRIDES : {} const hiddenKeys = HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? [] @@ -654,6 +780,137 @@ function formatScrewLength(length: string) { return length.trim().replace(/mm$/i, '') } +function escapeSvgText(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function getTemporaryReferencePalette(category: string) { + if (category.includes('全牙')) { + return { accent: '#2563eb', background: '#eff6ff' } + } + if (category.includes('双头螺杆')) { + return { accent: '#059669', background: '#ecfdf5' } + } + if (category.includes('双头牙')) { + return { accent: '#ca8a04', background: '#fefce8' } + } + if (category.includes('三合一')) { + return { accent: '#7c3aed', background: '#f5f3ff' } + } + if (category.includes('二合一')) { + return { accent: '#0891b2', background: '#ecfeff' } + } + if (category.includes('四合一')) { + return { accent: '#db2777', background: '#fdf2f8' } + } + if (category.includes('隐形')) { + return { accent: '#475569', background: '#f8fafc' } + } + if (category.includes('双头偏心')) { + return { accent: '#ea580c', background: '#fff7ed' } + } + return { accent: '#52525b', background: '#f4f4f5' } +} + +function renderThreadedRodArtwork(accent: string, category: string) { + const threads = Array.from({ length: 16 }, (_, index) => { + const x = 44 + index * 14 + return `` + }).join('') + const doubleHeadThreads = [46, 60, 74, 210, 224, 238] + .map( + (x) => + ``, + ) + .join('') + const pointedEnds = category.includes('双头牙') + ? ` + ` + : '' + + if (category.includes('全牙')) { + return ` + + ${threads} + ` + } + + return ` + ${pointedEnds} + + + ${doubleHeadThreads} + ` +} + +function renderEccentricConnectorArtwork(accent: string, category: string) { + const extraPart = category.includes('隐形') + ? ` + ` + : ` + ` + + return ` + + + + + + + + ${category.includes('双头偏心') ? '' : extraPart} + ` +} + +function getTemporaryReferenceImage(item: ScrewItem) { + if (item.imageUrl.trim()) return '' + if (item.letter !== 'C' && item.letter !== 'D') return '' + + const category = item.screwCategory.trim() || '配件' + const palette = getTemporaryReferencePalette(category) + const length = formatScrewLength(item.length) + const spec = + item.diameter.trim() && length + ? `${item.diameter.trim()}*${length}` + : item.diameter.trim() || length + const artwork = + item.letter === 'C' + ? renderThreadedRodArtwork(palette.accent, category) + : renderEccentricConnectorArtwork(palette.accent, category) + const title = escapeSvgText(category) + const subtitle = escapeSvgText(spec || item.name || '临时参考图') + const svg = ` + + + + ${title} + ${subtitle} + ${artwork} + + 临时参考图 + + ` + + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}` +} + +function getDisplayImageUrl(item: ScrewItem) { + return item.imageUrl.trim() || getTemporaryReferenceImage(item) +} + +function getDisplayImageName(item: ScrewItem) { + if (item.imageName.trim()) return item.imageName + if (getTemporaryReferenceImage(item)) { + return `${item.screwCategory || item.code} 临时参考图` + } + return `${item.code} 图片` +} + function buildScrewName( item: Pick< ScrewItem, @@ -664,6 +921,8 @@ function buildScrewName( | 'headType' | 'tail' | 'screwCategory' + | 'material' + | 'finish' >, ) { const diameter = item.diameter.trim() @@ -678,6 +937,16 @@ function buildScrewName( .join(' ') } + if (item.letter === 'D') { + const spec = + diameter && size ? `${diameter}*${size}` : diameter || size + const materialOrFinish = item.material.trim() || item.finish.trim() + return [spec, materialOrFinish, item.screwCategory] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + return [specSize, item.drive, item.headType, item.tail, item.screwCategory] .map((value) => value.trim()) .filter(Boolean) @@ -847,6 +1116,7 @@ export default function AccessoryLibraryPanel({ const [selectedLetter, setSelectedLetter] = useState('A') const [activeHeadType, setActiveHeadType] = useState('全部') + const [activeCategory, setActiveCategory] = useState('全部') const [draft, setDraft] = useState(null) const [editingId, setEditingId] = useState(null) const [copySource, setCopySource] = useState(null) @@ -885,10 +1155,19 @@ export default function AccessoryLibraryPanel({ [selectedLetter], ) const shouldShowHeadType = selectedHeadTypes.length > 0 + const selectedCategoryShortcuts = useMemo( + () => getCategoryShortcuts(selectedLetter), + [selectedLetter], + ) + const shouldShowCategoryShortcuts = selectedCategoryShortcuts.length > 0 const selectedSelectFields = useMemo( () => getSelectFields(selectedLetter), [selectedLetter], ) + const selectedHiddenFieldKeys = useMemo( + () => getHiddenFieldKeys(selectedLetter, shouldShowHeadType), + [selectedLetter, shouldShowHeadType], + ) const selectedTableColumns = useMemo( () => { const columns = getTableColumns(selectedLetter, shouldShowHeadType) @@ -919,6 +1198,11 @@ export default function AccessoryLibraryPanel({ shouldShowHeadType ? appliedFilters.headType || (activeHeadType === '全部' ? '' : activeHeadType) : '' + const categoryFilter = + shouldShowCategoryShortcuts + ? appliedFilters.screwCategory || + (activeCategory === '全部' ? '' : activeCategory) + : appliedFilters.screwCategory if (codeKeyword && !normalizeText(item.code).includes(codeKeyword)) { return false @@ -932,17 +1216,24 @@ export default function AccessoryLibraryPanel({ return false } + if (categoryFilter && item.screwCategory !== categoryFilter) { + return false + } + return selectedSelectFields.every((field) => { const filterValue = appliedFilters[field.key] + if (field.key === 'screwCategory') return true return !filterValue || item[field.key] === filterValue }) }) }, [ + activeCategory, activeHeadType, appliedFilters, isSelectedLetterConfigured, selectedLetterItems, selectedSelectFields, + shouldShowCategoryShortcuts, shouldShowHeadType, ]) @@ -956,12 +1247,14 @@ export default function AccessoryLibraryPanel({ function applyFilters() { setAppliedFilters(filterDraft) setActiveHeadType(filterDraft.headType || '全部') + setActiveCategory(filterDraft.screwCategory || '全部') } function resetFilters() { setFilterDraft(EMPTY_FILTERS) setAppliedFilters(EMPTY_FILTERS) setActiveHeadType('全部') + setActiveCategory('全部') } function selectLetter(letter: AccessoryLibraryLetter) { @@ -969,6 +1262,7 @@ export default function AccessoryLibraryPanel({ setFilterDraft(EMPTY_FILTERS) setAppliedFilters(EMPTY_FILTERS) setActiveHeadType('全部') + setActiveCategory('全部') } function selectHeadType(headType: string) { @@ -984,6 +1278,19 @@ export default function AccessoryLibraryPanel({ })) } + function selectCategory(category: string) { + const nextCategory = category === '全部' ? '' : category + setActiveCategory(category) + setFilterDraft((current) => ({ + ...current, + screwCategory: nextCategory, + })) + setAppliedFilters((current) => ({ + ...current, + screwCategory: nextCategory, + })) + } + function openCreateModal(source?: ScrewItem) { if (!canEditItems) return @@ -1030,6 +1337,8 @@ export default function AccessoryLibraryPanel({ 'headType', 'tail', 'screwCategory', + 'material', + 'finish', ].includes(key) ) { return { @@ -1344,6 +1653,39 @@ export default function AccessoryLibraryPanel({
) : null} + {isSelectedLetterConfigured && shouldShowCategoryShortcuts ? ( +
+ + {selectedCategoryShortcuts.map((category) => { + const isActive = activeCategory === category + return ( + + ) + })} +
+ ) : null} + {isSelectedLetterConfigured ? (
@@ -1369,7 +1711,10 @@ export default function AccessoryLibraryPanel({ {visibleItems.length ? ( - visibleItems.map((item, index) => ( + visibleItems.map((item, index) => { + const displayImageUrl = getDisplayImageUrl(item) + + return ( ) : null} - {selectedLetter !== 'C' ? ( + {!selectedHiddenFieldKeys.has('drive') ? ( ) : null} - + {!selectedHiddenFieldKeys.has('tail') ? ( + + ) : null} - - + {!selectedHiddenFieldKeys.has('finish') ? ( + + ) : null} + {!selectedHiddenFieldKeys.has('tooth') ? ( + + ) : null} - + {!selectedHiddenFieldKeys.has('length') ? ( + + ) : null} - - + {!selectedHiddenFieldKeys.has('color') ? ( + + ) : null} + {!selectedHiddenFieldKeys.has('preDrill') ? ( + + ) : null} @@ -1487,7 +1844,8 @@ export default function AccessoryLibraryPanel({ ) : null} - )) + ) + }) ) : ( {[ "型号", - "规格码", + "货号", "套件量", "供应商名称", "条码", @@ -23294,7 +23575,7 @@ export default function NonIntegratedDetailClient({ "序号", "图片", "型号", - "规格码", + "货号", "套件量", "供应商名称", "条码", @@ -23824,7 +24105,7 @@ export default function NonIntegratedDetailClient({ "序号", "图片", "型号", - "规格码", + "货号", "条码", "套件量", "规格名称", @@ -24113,7 +24394,7 @@ export default function NonIntegratedDetailClient({ {[ "产品图片", "SPU", - "规格码", + "货号", "条码", "套件量", "规格名称", @@ -24227,7 +24508,7 @@ export default function NonIntegratedDetailClient({ {[ "型号", - "规格码", + "货号", "条码", "套件量", "规格名称", @@ -24509,7 +24790,7 @@ function renderFactoryQualityWorkspace() { "批次", "产品图", "型号", - "规格码", + "货号", "条码", "套件量", "规格名称", @@ -28304,7 +28585,7 @@ function renderFactoryQualityWorkspace() { "序号", "图片", "型号", - "规格码", + "货号", "条码", "套件量", "规格名称", @@ -29586,6 +29867,27 @@ function renderFactoryQualityWorkspace() { nodePositionOffsets={previewNodeOffsets} nodeRotationStates={previewNodeRotations} materialAssignments={previewMaterialAssignments} + holes={ + isPackagingModelPreviewActive + ? [] + : previewHoleOverlayState.holes + } + activeHolePanelIds={ + isPackagingModelPreviewActive + ? [] + : previewHoleOverlayState.activeHolePanelIds + } + panelNodeNameByPanelId={ + isPackagingModelPreviewActive + ? {} + : previewHoleOverlayState.panelNodeNameByPanelId + } + panelLabelByPanelId={ + isPackagingModelPreviewActive + ? {} + : previewHoleOverlayState.panelLabelByPanelId + } + showHoleHighlights={!isPackagingModelPreviewActive} onNodePositionOffsetChange={ isReadOnly ? undefined diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts index e2dd0a87..aa9dd74e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts @@ -367,6 +367,7 @@ export type NonIntegratedSkuItem = { model3dNodeMaterialIds?: Record; model3dUvConfig?: AssetVariantUvConfig | null; componentDetails?: NonIntegratedSkuComponentDetail[]; + holePoints?: NonIntegratedSkuHolePoint[]; source?: string; sourceTable?: string; sourceSkuId?: string; @@ -381,6 +382,18 @@ export type NonIntegratedSkuComponentDetail = { assetType: string; }; +export type NonIntegratedSkuHolePoint = { + id: string; + panelId: string; + panelName: string; + position: [number, number, number]; + normal: [number, number, number]; + diameter: number; + depth: number; + holeType: string; + sourceNodeName?: string; +}; + export type NonIntegratedFactoryRow = { skuId: string; confirmed: boolean; @@ -1210,6 +1223,67 @@ export function normalizeNonIntegratedSkuComponentDetails( .filter((item): item is NonIntegratedSkuComponentDetail => item !== null); } +function normalizeNumberTuple3(value: unknown): [number, number, number] | null { + if (!Array.isArray(value) || value.length < 3) return null; + + const tuple = value.slice(0, 3); + if ( + !tuple.every((item) => typeof item === "number" && Number.isFinite(item)) + ) { + return null; + } + + return tuple as [number, number, number]; +} + +function normalizeSkuHolePoint(value: unknown): NonIntegratedSkuHolePoint | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const item = value as Partial; + const position = normalizeNumberTuple3(item.position); + if (!position) return null; + + const id = normalizeString(item.id).trim(); + const panelId = normalizeString(item.panelId).trim(); + const sourceNodeName = normalizeString(item.sourceNodeName).trim(); + const stableId = + id || + [ + panelId || sourceNodeName || "model", + position.map((entry) => entry.toFixed(3)).join("_"), + ].join(":"); + + return { + id: stableId, + panelId: panelId || sourceNodeName || "model-root", + panelName: normalizeString(item.panelName).trim(), + position, + normal: normalizeNumberTuple3(item.normal) ?? [0, 1, 0], + diameter: + typeof item.diameter === "number" && Number.isFinite(item.diameter) + ? item.diameter + : 5, + depth: + typeof item.depth === "number" && Number.isFinite(item.depth) + ? item.depth + : 10, + holeType: normalizeString(item.holeType).trim() || "mount-point", + ...(sourceNodeName ? { sourceNodeName } : {}), + }; +} + +export function normalizeNonIntegratedSkuHolePoints( + value: unknown, +): NonIntegratedSkuHolePoint[] { + if (!Array.isArray(value)) return []; + + return value + .map((item) => normalizeSkuHolePoint(item)) + .filter((item): item is NonIntegratedSkuHolePoint => item !== null); +} + function normalizeSkuItem( item: Partial, ): NonIntegratedSkuItem | null { @@ -1267,6 +1341,7 @@ function normalizeSkuItem( componentDetails: normalizeNonIntegratedSkuComponentDetails( item.componentDetails, ), + holePoints: normalizeNonIntegratedSkuHolePoints(item.holePoints), source: normalizeString(item.source), sourceTable: normalizeString(item.sourceTable), sourceSkuId: normalizeString(item.sourceSkuId), diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 7f03af53..3a2feae1 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -8,6 +8,7 @@ import { materialService } from '@/server/services/material.service' import { warehouseService } from '@/server/services/warehouse.service' import IntakeClient from './IntakeClient' import { readPreSkuComponentDetails, readPreSkuSkuFields } from './seriesComponentDetails' +import type { NonIntegratedSkuHolePoint } from './non-integrated/nonIntegratedStorage' function readPreSkuTextField( preSku: { @@ -36,6 +37,119 @@ function mapWorkbenchStatus(status: string) { return '待处理' } +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function readFiniteNumber(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function readVector3Tuple(value: unknown): [number, number, number] | null { + if (!Array.isArray(value) || value.length < 3) return null + const x = readFiniteNumber(value[0]) + const y = readFiniteNumber(value[1]) + const z = readFiniteNumber(value[2]) + if (x === null || y === null || z === null) return null + return [x, y, z] +} + +function readRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is Record => isPlainRecord(item)) + : [] +} + +function readTrimmedString(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function buildSkuHolePointFromRecord(params: { + record: Record + fallbackId: string + panelId: string + panelName: string + sourceNodeName: string +}): NonIntegratedSkuHolePoint | null { + const position = readVector3Tuple(params.record.position) + if (!position) return null + + return { + id: readTrimmedString(params.record.id) || params.fallbackId, + panelId: readTrimmedString(params.record.panelId) || params.panelId, + panelName: readTrimmedString(params.record.panelName) || params.panelName, + position, + normal: readVector3Tuple(params.record.normal) ?? [0, 1, 0], + diameter: readFiniteNumber(params.record.diameter) ?? readFiniteNumber(params.record.width) ?? 5, + depth: readFiniteNumber(params.record.depth) ?? 10, + holeType: + readTrimmedString(params.record.holeType) || + readTrimmedString(params.record.type) || + 'mount-point', + sourceNodeName: params.sourceNodeName, + } +} + +function buildSkuHolePointsFromVariantSelection(preSku: { + variantSelection: { + slots?: Array<{ + variantId: string + code: string + name: string + mountPoints: unknown[] + assemblyBasis: Record | null + }> + hardwareSelections?: Array<{ + variantId: string + code: string + name: string + mountPoints: unknown[] + assemblyBasis: Record | null + }> + } +}): NonIntegratedSkuHolePoint[] { + const entries = [ + ...(preSku.variantSelection.slots ?? []), + ...(preSku.variantSelection.hardwareSelections ?? []), + ] + + return entries.flatMap((entry, entryIndex) => { + const sourceNodeName = entry.code || entry.name || `node-${entryIndex + 1}` + const panelId = entry.variantId || sourceNodeName + const panelName = entry.name || sourceNodeName + const assemblyBasis = isPlainRecord(entry.assemblyBasis) ? entry.assemblyBasis : null + const holes = readRecordArray(assemblyBasis?.holes) + .map((record, holeIndex) => + buildSkuHolePointFromRecord({ + record, + fallbackId: `${panelId}:hole-${holeIndex + 1}`, + panelId, + panelName, + sourceNodeName, + }), + ) + .filter((item): item is NonIntegratedSkuHolePoint => item !== null) + + if (holes.length > 0) return holes + + return readRecordArray(entry.mountPoints) + .map((record, mountIndex) => + buildSkuHolePointFromRecord({ + record: { + ...record, + id: readTrimmedString(record.id) || readTrimmedString(record.name) || `mount-${mountIndex + 1}`, + holeType: readTrimmedString(record.type) || 'mount-point', + }, + fallbackId: `${panelId}:mount-${mountIndex + 1}`, + panelId, + panelName, + sourceNodeName, + }), + ) + .filter((item): item is NonIntegratedSkuHolePoint => item !== null) + }) +} + function isDraftGroupEligibleForIntake(status: string) { return status === 'PACKAGING' || status === 'READY_FOR_INTAKE' } @@ -127,6 +241,7 @@ export default async function IntakePage({ status: mapWorkbenchStatus(preSku.status), componentCodes: preSku.componentCodes ?? [], componentDetails: readPreSkuComponentDetails(preSku), + holePoints: buildSkuHolePointsFromVariantSelection(preSku), skuFields: readPreSkuSkuFields(preSku), })), ) diff --git a/furniture-3d-system/src/server/services/approval-workflow.service.ts b/furniture-3d-system/src/server/services/approval-workflow.service.ts index 1f933b08..6b69c2b0 100644 --- a/furniture-3d-system/src/server/services/approval-workflow.service.ts +++ b/furniture-3d-system/src/server/services/approval-workflow.service.ts @@ -196,11 +196,11 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [ key: 'spuSkuIdentity', label: 'SPU / SKU / 货号信息', section: '基础资料', - helperText: '对应正式产品和仓库里都会用到的 SPU、SKU、型号、规格码、货号。', + helperText: '对应正式产品和仓库里都会用到的 SPU、SKU、型号、货号。', businessRole: 'WAREHOUSE', required: true, allowSecondEdit: true, - placeholder: '填写 SPU、SKU、型号、规格码、条码规格名或货号承接信息。', + placeholder: '填写 SPU、SKU、型号、条码规格名或货号承接信息。', }, { key: 'baseAttributeSummary', From d8c08bd62588eb346bee0c16a1894cb0f8367757 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 28 May 2026 10:44:10 +0800 Subject: [PATCH 6/8] fix(intake): guard stock product workspace saves --- .../api/intake/non-integrated/[id]/route.ts | 7 + .../NonIntegratedDetailClient.tsx | 248 ++-- .../intake/non-integrated/nonIntegratedApi.ts | 12 +- ...ake-non-integrated-product.service.test.ts | 347 ++++++ .../intake-non-integrated-product.service.ts | 1008 ++++++++++++++++- 5 files changed, 1462 insertions(+), 160 deletions(-) diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts index 3022b58e..297275cc 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts @@ -43,6 +43,12 @@ export async function PUT(request: NextRequest, context: RouteContext) { const rawBody = await request.json().catch(() => null) const product = rawBody && typeof rawBody === 'object' ? (rawBody as { product?: unknown }).product : null + const workspaceCategory = + rawBody && + typeof rawBody === 'object' && + typeof (rawBody as { workspaceCategory?: unknown }).workspaceCategory === 'string' + ? ((rawBody as { workspaceCategory: string }).workspaceCategory.trim() || null) + : null if (!product || typeof product !== 'object' || Array.isArray(product)) { return jsonError( @@ -60,6 +66,7 @@ export async function PUT(request: NextRequest, context: RouteContext) { id: decodedId, }, ctx.user, + { workspaceCategory }, ) return jsonSuccess({ product: savedProduct }, 200, ctx.requestId) } catch (error) { 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 04159677..2c2b361d 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -5814,6 +5814,7 @@ type PersistEditedProductOverrides = { skuBatchConfirmedAt?: Record; projectStatus?: NonIntegratedProjectStatus; planningLayoutRows?: NonIntegratedPlanningLayoutRow[]; + workspaceCategory?: DocumentWorkspaceCategory; allowReadOnly?: boolean; }; @@ -11613,124 +11614,6 @@ export default function NonIntegratedDetailClient({ currentUserRole, ], ); - const activePermissionWorkspaceCategory = - documentWorkspaceView === "detail" - ? NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY - : activeDocumentWorkspaceCategory; - const activeWorkspaceNodePermissionAccess = - getNodePermissionAccessForCategory(activePermissionWorkspaceCategory); - const canEditCurrentProduct = - mode === "create" - ? !hasCurrentUserContext || - canCreateNonIntegratedProductFromIntake(currentPermissionUser) - : !hasCurrentUserContext || - canEditNonIntegratedProductIdentity( - currentPermissionProduct, - currentPermissionUser, - ); - const canEditActiveWorkspace = - !hasCurrentUserContext || - (mode === "create" - ? canEditCurrentProduct - : canEditNonIntegratedWorkspace( - currentPermissionProduct, - currentPermissionUser, - activePermissionWorkspaceCategory, - ) || - (activeWorkspaceNodePermissionAccess.override.edit && - canDownloadNonIntegratedWorkspace( - currentPermissionProduct, - currentPermissionUser, - activePermissionWorkspaceCategory, - ))); - const isReadOnly = - mode === "view" || isFlowAbandoned || !canEditActiveWorkspace; - const currentUserDesignerNameKey = - normalizePersonPermissionName(currentUserName); - const assignedDesignerNameKeys = useMemo( - () => - new Set( - [ - formValues.designer, - openingReference.designer, - openingReference.structureDesigner, - ] - .map(normalizePersonPermissionName) - .filter(Boolean), - ), - [ - formValues.designer, - openingReference.designer, - openingReference.structureDesigner, - ], - ); - const canEditPreviewModelMaterial = - !isReadOnly && - (!hasCurrentUserContext || - isCurrentUserAdmin || - (currentUserDesignerNameKey.length > 0 && - assignedDesignerNameKeys.has(currentUserDesignerNameKey))); - const canAbandonProduct = - mode === "edit" && !isCurrentUserFullDetailEditor && canEditCurrentProduct; - const sharedUploadProductId = - mode === "edit" && !isReadOnly && productId ? productId : undefined; - const detachedUploadOptions = sharedUploadProductId - ? { - productId: sharedUploadProductId, - } - : undefined; - const uploadProgressPercent = uploadProgress - ? clampPercent(uploadProgress.percent) - : 0; - const downloadProgressPercent = downloadProgress - ? clampPercent(downloadProgress.percent) - : 0; - const hasLinkEntryProductPatentTab = linkEntryWorkspaceTabs.some( - (tab) => tab.kind === "productPatentLedger", - ); - - useEffect(() => { - if (!hasLinkEntryProductPatentTab || LinkEntryProductPatentDashboard) { - return; - } - - let isMounted = true; - void import("@/app/intake/IntakeClient").then((module) => { - if (!isMounted) return; - setLinkEntryProductPatentDashboard( - () => module.ProductPatentDashboard, - ); - }); - - return () => { - isMounted = false; - }; - }, [LinkEntryProductPatentDashboard, hasLinkEntryProductPatentTab]); - - useEffect(() => { - const refreshSpuEstimate = () => - setSpuEstimateRefreshToken((current) => current + 1); - - window.addEventListener("storage", refreshSpuEstimate); - window.addEventListener( - NON_INTEGRATED_PRODUCTS_UPDATED, - refreshSpuEstimate, - ); - window.addEventListener(SPU_RULE_LIBRARY_UPDATED_EVENT, refreshSpuEstimate); - - return () => { - window.removeEventListener("storage", refreshSpuEstimate); - window.removeEventListener( - NON_INTEGRATED_PRODUCTS_UPDATED, - refreshSpuEstimate, - ); - window.removeEventListener( - SPU_RULE_LIBRARY_UPDATED_EVENT, - refreshSpuEstimate, - ); - }; - }, []); - const isDocumentWorkspaceMode = documentWorkspaceView === "document"; const sharedProductImage = openingReference.productImage.trim() || formValues.previewImage.trim(); @@ -11836,6 +11719,129 @@ export default function NonIntegratedDetailClient({ existingProduct?.processTemplate?.trim() === STOCK_PRODUCT_LABEL || normalizedExistingOpeningReference.currentStage.trim() === STOCK_PRODUCT_LABEL; + const activePermissionWorkspaceCategory = + documentWorkspaceView === "detail" + ? NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY + : activeDocumentWorkspaceCategory; + const stockProductCanEditActiveWorkspace = + !isStockProductRecord || + activePermissionWorkspaceCategory === PLANNING_LAYOUT_WORKSPACE_CATEGORY; + const activeWorkspaceNodePermissionAccess = + getNodePermissionAccessForCategory(activePermissionWorkspaceCategory); + const canEditCurrentProduct = + mode === "create" + ? !hasCurrentUserContext || + canCreateNonIntegratedProductFromIntake(currentPermissionUser) + : !hasCurrentUserContext || + (stockProductCanEditActiveWorkspace && + canEditNonIntegratedProductIdentity( + currentPermissionProduct, + currentPermissionUser, + )); + const canEditActiveWorkspace = + !hasCurrentUserContext || + (mode === "create" + ? canEditCurrentProduct + : stockProductCanEditActiveWorkspace && + (canEditNonIntegratedWorkspace( + currentPermissionProduct, + currentPermissionUser, + activePermissionWorkspaceCategory, + ) || + (activeWorkspaceNodePermissionAccess.override.edit && + canDownloadNonIntegratedWorkspace( + currentPermissionProduct, + currentPermissionUser, + activePermissionWorkspaceCategory, + )))); + const isReadOnly = + mode === "view" || isFlowAbandoned || !canEditActiveWorkspace; + const currentUserDesignerNameKey = + normalizePersonPermissionName(currentUserName); + const assignedDesignerNameKeys = useMemo( + () => + new Set( + [ + formValues.designer, + openingReference.designer, + openingReference.structureDesigner, + ] + .map(normalizePersonPermissionName) + .filter(Boolean), + ), + [ + formValues.designer, + openingReference.designer, + openingReference.structureDesigner, + ], + ); + const canEditPreviewModelMaterial = + !isReadOnly && + (!hasCurrentUserContext || + isCurrentUserAdmin || + (currentUserDesignerNameKey.length > 0 && + assignedDesignerNameKeys.has(currentUserDesignerNameKey))); + const canAbandonProduct = + mode === "edit" && !isCurrentUserFullDetailEditor && canEditCurrentProduct; + const sharedUploadProductId = + mode === "edit" && !isReadOnly && productId ? productId : undefined; + const detachedUploadOptions = sharedUploadProductId + ? { + productId: sharedUploadProductId, + } + : undefined; + const uploadProgressPercent = uploadProgress + ? clampPercent(uploadProgress.percent) + : 0; + const downloadProgressPercent = downloadProgress + ? clampPercent(downloadProgress.percent) + : 0; + const hasLinkEntryProductPatentTab = linkEntryWorkspaceTabs.some( + (tab) => tab.kind === "productPatentLedger", + ); + + useEffect(() => { + if (!hasLinkEntryProductPatentTab || LinkEntryProductPatentDashboard) { + return; + } + + let isMounted = true; + void import("@/app/intake/IntakeClient").then((module) => { + if (!isMounted) return; + setLinkEntryProductPatentDashboard( + () => module.ProductPatentDashboard, + ); + }); + + return () => { + isMounted = false; + }; + }, [LinkEntryProductPatentDashboard, hasLinkEntryProductPatentTab]); + + useEffect(() => { + const refreshSpuEstimate = () => + setSpuEstimateRefreshToken((current) => current + 1); + + window.addEventListener("storage", refreshSpuEstimate); + window.addEventListener( + NON_INTEGRATED_PRODUCTS_UPDATED, + refreshSpuEstimate, + ); + window.addEventListener(SPU_RULE_LIBRARY_UPDATED_EVENT, refreshSpuEstimate); + + return () => { + window.removeEventListener("storage", refreshSpuEstimate); + window.removeEventListener( + NON_INTEGRATED_PRODUCTS_UPDATED, + refreshSpuEstimate, + ); + window.removeEventListener( + SPU_RULE_LIBRARY_UPDATED_EVENT, + refreshSpuEstimate, + ); + }; + }, []); + const isSkuContentReadOnly = isReadOnly; const activeSkuBatchNo = activeSkuBatch === "all" ? null : activeSkuBatch; const formalSpuValue = formValues.spu.trim(); @@ -14670,7 +14676,10 @@ export default function NonIntegratedDetailClient({ ); const savedProduct = shouldUseRemoteAssetStorage() - ? await saveSharedNonIntegratedProduct(nextProduct) + ? await saveSharedNonIntegratedProduct(nextProduct, { + workspaceCategory: + overrides?.workspaceCategory ?? activePermissionWorkspaceCategory, + }) : saveNonIntegratedProduct(nextProduct); if ( nextDocumentWorkspaceConfirmations[ @@ -14710,6 +14719,7 @@ export default function NonIntegratedDetailClient({ isStockProductRecord, currentUserIdNormalized, currentUserNameNormalized, + activePermissionWorkspaceCategory, isReadOnly, mode, normalizeProductAssetsForStorage, 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 8b158088..b4c42e1b 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts @@ -256,13 +256,21 @@ export async function getSharedNonIntegratedProductById(id: string) { return data.product } -export async function saveSharedNonIntegratedProduct(product: NonIntegratedProductRecord) { +export async function saveSharedNonIntegratedProduct( + product: NonIntegratedProductRecord, + options: { workspaceCategory?: string | null } = {}, +) { const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(product.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ product }), + body: JSON.stringify({ + product, + ...(options.workspaceCategory + ? { workspaceCategory: options.workspaceCategory } + : {}), + }), }) const data = await readApiData<{ product: NonIntegratedProductRecord }>(response) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index 42290c30..0272a48c 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -608,6 +608,353 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).toHaveBeenCalled() }) + it('allows space visual users to save stock planning layout without rewriting readonly legacy fields', async () => { + const stockSku = { + id: 'jst-stock-sku-4fe2f2b80cfa6ad08100', + skuSequence: 1, + batchNo: 1, + packageGroupId: '', + packageSuffix: '', + image: 'https://static.jiansun.vip:3333/Resource/SkuImg/GBPM-AD030203N1280V01.jpg', + imageName: 'GBPM-AD030203N1280V01.jpg', + model: '', + skuCode: 'GBPM-AD030203', + setCount: '1', + specName: 'GBPM-款式一 32/32/45 朴橡胡桃木色', + barcode: 'GBPM-AD030203N1280V01', + size: '32/32/45', + color: '朴橡胡桃木色', + material: '橡胶木', + attributeCode: '', + drawingId: 'AD0302K01AV01', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + salesStatus: '销售', + tagMaterial: '', + versionNo: 'V01', + model3dFileName: '', + model3dDataUrl: '', + model3dNodeOffsets: {}, + model3dNodeRotations: {}, + componentDetails: [], + source: 'jst_sku', + sourceTable: 'jst_sku', + sourceSkuId: 'GBPM-AD030203N1280V01', + contentLocked: false, + } + const existingPayload = createPayload({ + id: 'jst-stock-edd94032a9d1022f', + spu: 'GBPM-AD0302', + productCode: 'GBPM-AD0302', + productDevelopmentKind: 'product', + processTemplate: '存量商品', + brand: '基本盘', + adaptableBrands: ['基本盘'], + designer: '', + previewImage: + 'https://static.jiansun.vip:3333/Resource/SkuImg/GBPM-AD030203N1280V01.jpg', + previewImageName: 'GBPM-AD030203N1280V01.jpg', + productCategory: '住宅家具 - 矮凳', + salesCategory: '住宅家具 - 矮凳', + size: '32/32/45', + materialType: '橡胶木', + projectStatus: 'approved', + openingReference: { + productImage: + 'https://static.jiansun.vip:3333/Resource/SkuImg/GBPM-AD030203N1280V01.jpg', + productImageName: 'GBPM-AD030203N1280V01.jpg', + productName: 'GBPM-款式一 32/32/45 朴橡胡桃木色', + temporaryCode: 'GBPM-AD0302', + brand: '基本盘', + adaptableBrands: ['基本盘'], + productCategory: '住宅家具 - 矮凳', + salesCategory: '住宅家具 - 矮凳', + designer: '', + planningOwner: '', + structureDesigner: '', + currentStage: '存量商品', + year: '2026', + competitorLink: '', + targetPriceBand: '', + size: '32/32/45', + material: '橡胶木', + urgent: '', + reason: '', + }, + patentApplication: { + shouldApply: '待判断', + patentName: '', + patentStatus: '待确认', + saRequired: '否', + salesStatus: '在售', + designer: '', + applicationNo: '', + applicationDate: '', + applicationResult: '', + resultDate: '', + patentNo: '', + firstFeeDate: '', + nextFeeDueDate: '', + feeAmount: '', + owner: '', + note: '', + }, + skuItems: [stockSku], + planningLayoutRows: [], + formalSpuAssigned: true, + }) + const incomingPayload = createPayload({ + ...existingPayload, + brand: '其他品牌-普通大货', + adaptableBrands: ['其他品牌-普通大货'], + openingReference: { + ...(existingPayload.openingReference as Record), + brand: '其他品牌-普通大货', + adaptableBrands: ['其他品牌-普通大货'], + }, + patentApplication: { + ...(existingPayload.patentApplication as Record), + patentName: 'GBPM-款式一 32/32/45 朴橡胡桃木色', + }, + planningLayoutRows: [ + { + skuId: stockSku.id, + style: '', + styles: ['中古风'], + styleTone: '', + styleTones: ['深木色系'], + audience: '', + audiences: ['租房人群'], + spaces: ['餐厅'], + categories: [], + model: '', + priceBand: '', + brands: ['自有品牌-朴美'], + shop: '', + }, + ], + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + id: 'jst-stock-edd94032a9d1022f', + spu: 'GBPM-AD0302', + spuBase: 'AD0302', + brand: '其他品牌-普通大货', + designer: '', + purchaser: '', + payload: existingPayload, + }) as never, + ) + mockedRepository.update.mockResolvedValue( + createRecord({ + id: 'jst-stock-edd94032a9d1022f', + payload: incomingPayload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'jst-stock-edd94032a9d1022f', + payload: incomingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'space-visual-1', + email: 'space@example.com', + name: '希文', + businessRoles: ['PLANNING'] as const, + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + }, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + ], + }), + ).resolves.toMatchObject({ + planningLayoutRows: [ + expect.objectContaining({ + skuId: stockSku.id, + styles: ['中古风'], + }), + ], + }) + + const updateData = mockedRepository.update.mock.calls[0]?.[1] as + | Record + | undefined + const updatePayload = updateData?.payload as Record + expect(updateData).toEqual( + expect.objectContaining({ + brand: '其他品牌-普通大货', + designer: '', + purchaser: '', + }), + ) + expect(updatePayload).toEqual( + expect.objectContaining({ + brand: '基本盘', + adaptableBrands: ['基本盘'], + patentApplication: expect.objectContaining({ + patentName: '', + }), + planningLayoutRows: [ + expect.objectContaining({ + skuId: stockSku.id, + styles: ['中古风'], + }), + ], + }), + ) + expect(updatePayload.openingReference).toEqual( + expect.objectContaining({ + brand: '基本盘', + adaptableBrands: ['基本盘'], + }), + ) + }) + + it('rejects stock product factory edits even from purchase owners', async () => { + const existingFactoryRows = [ + { + skuId: 'stock-sku-1', + confirmed: false, + model: 'AD0302', + skuCode: 'GBPM-AD030201', + setCount: '1', + barcode: 'GBPM-AD030201N1280V01', + specName: '存量款式', + size: '', + color: '', + material: '', + installMethod: '', + supplierName: '供应商A', + supplierCode: 'N1280', + warehouse: '', + minimumOrderQuantity: '', + leadTime: '', + assemblyMode: '', + productionType: '', + installationService: '', + supplierCategory: '', + purchaseOrderMethod: '', + executeStandard: '', + }, + ] + const existingPayload = createPayload({ + id: 'stock-product-factory-readonly', + processTemplate: '存量商品', + purchaser: '采购A', + purchaseOwner: '采购A', + openingReference: { + currentStage: '存量商品', + }, + factoryRows: existingFactoryRows, + }) + const incomingPayload = createPayload({ + ...existingPayload, + factoryRows: existingFactoryRows.map((row) => ({ + ...row, + confirmed: true, + supplierName: '供应商B', + })), + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + id: 'stock-product-factory-readonly', + payload: existingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'purchase-1', + email: 'purchase@example.com', + name: '采购A', + businessRoles: [] as const, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部', + ], + }), + ).rejects.toThrow('没有权限编辑这条非集成产品记录') + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + + it('does not rewrite stock product legacy values when there is no permitted node change', async () => { + const existingPayload = createPayload({ + id: 'stock-readonly-noop-save', + spu: 'GBPM-AD0302', + productCode: 'GBPM-AD0302', + processTemplate: '存量商品', + brand: '基本盘', + adaptableBrands: ['基本盘'], + openingReference: { + brand: '基本盘', + adaptableBrands: ['基本盘'], + currentStage: '存量商品', + }, + planningLayoutRows: [], + }) + const incomingPayload = createPayload({ + ...existingPayload, + brand: '其他品牌-普通大货', + adaptableBrands: ['其他品牌-普通大货'], + openingReference: { + ...(existingPayload.openingReference as Record), + brand: '其他品牌-普通大货', + adaptableBrands: ['其他品牌-普通大货'], + }, + planningLayoutRows: [], + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + id: 'stock-readonly-noop-save', + spu: 'GBPM-AD0302', + payload: existingPayload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'stock-readonly-noop-save', + spu: 'GBPM-AD0302', + payload: existingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'space-visual-1', + email: 'space@example.com', + name: '希文', + businessRoles: ['PLANNING'] as const, + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + }, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + ], + }), + ).resolves.toEqual(expect.objectContaining({ id: 'stock-readonly-noop-save' })) + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + it('keeps existing BOM details when an autosave submits an empty BOM payload', async () => { const existingBomDetails = [ { diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 2a0716fb..145fe72b 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -95,6 +95,23 @@ type IntakeNonIntegratedRecord = NonNullable< type NonIntegratedWorkspaceCategoryKey = keyof NonIntegratedDocumentWorkspaceConfirmationMap +type PermittedProductWriteResult = { + product: SharedNonIntegratedProductRecord + preserveRecordColumns?: boolean + skipUpdate?: boolean +} + +type DuplicateProductCheckInput = Pick< + SharedNonIntegratedProductRecord, + 'id' | 'brand' +> & { + spu: string +} + +type SaveNonIntegratedProductOptions = { + workspaceCategory?: string | null +} + export type IntakeNonIntegratedProductListParams = { page?: number pageSize?: number @@ -134,6 +151,24 @@ const PRODUCT_LIMIT_WORKSPACE_CATEGORY = '产品限价' const PLANNING_LAYOUT_WORKSPACE_CATEGORY = '企划布局' const FACTORY_QUALITY_WORKSPACE_CATEGORY = '工厂质检' +const TRACKED_WORKSPACE_CATEGORIES = [ + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + OPENING_REFERENCE_WORKSPACE_CATEGORY, + PROJECT_REVIEW_WORKSPACE_CATEGORY, + REVIEW_WORKSPACE_CATEGORY, + PATENT_APPLICATION_WORKSPACE_CATEGORY, + PRODUCT_DATA_WORKSPACE_CATEGORY, + INSTALLATION_WORKSPACE_CATEGORY, + PACKAGING_WORKSPACE_CATEGORY, + PRODUCT_FACTORY_WORKSPACE_CATEGORY, + SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, + FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, + PRODUCT_PRICING_WORKSPACE_CATEGORY, + PRODUCT_LIMIT_WORKSPACE_CATEGORY, + PLANNING_LAYOUT_WORKSPACE_CATEGORY, + FACTORY_QUALITY_WORKSPACE_CATEGORY, +] as const satisfies readonly NonIntegratedWorkspaceCategoryKey[] + const BASIC_INFO_FIELD_KEYS = [ 'productDevelopmentKind', 'spu', @@ -297,6 +332,13 @@ function comparableEquals(left: unknown, right: unknown) { return stableStringify(left) === stableStringify(right) } +function isStockNonIntegratedProduct(product: SharedNonIntegratedProductRecord) { + return ( + product.processTemplate?.trim() === '存量商品' || + product.openingReference?.currentStage?.trim() === '存量商品' + ) +} + function pickProductFields( product: SharedNonIntegratedProductRecord, keys: readonly string[], @@ -304,6 +346,648 @@ function pickProductFields( return Object.fromEntries(keys.map((key) => [key, product[key]])) } +function preserveFieldsFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + sourceProduct: SharedNonIntegratedProductRecord, + keys: readonly string[], +) { + return { + ...nextProduct, + ...Object.fromEntries(keys.map((key) => [key, sourceProduct[key]])), + } as SharedNonIntegratedProductRecord +} + +function preserveFieldsFromPayload( + nextProduct: SharedNonIntegratedProductRecord, + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, + keys: readonly string[], +) { + const payloadRecord = isRecord(sourcePayload) ? sourcePayload : {} + + return { + ...nextProduct, + ...Object.fromEntries( + keys.map((key) => [ + key, + Object.prototype.hasOwnProperty.call(payloadRecord, key) + ? payloadRecord[key] + : fallbackProduct[key], + ]), + ), + } as SharedNonIntegratedProductRecord +} + +function preserveOpeningReferenceFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + sourceProduct: SharedNonIntegratedProductRecord, +) { + return { + ...nextProduct, + openingReference: sourceProduct.openingReference, + } +} + +function preserveOpeningReferenceFromPayload( + nextProduct: SharedNonIntegratedProductRecord, + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, +) { + const payloadRecord = isRecord(sourcePayload) ? sourcePayload : {} + + return { + ...nextProduct, + openingReference: Object.prototype.hasOwnProperty.call( + payloadRecord, + 'openingReference', + ) + ? payloadRecord.openingReference + : fallbackProduct.openingReference, + } as SharedNonIntegratedProductRecord +} + +const PRODUCT_DATA_FIELD_KEYS = [ + 'model3dFileName', + 'model3dDataUrl', + 'model3dNodeOffsets', + 'model3dNodeRotations', + 'model3dNodeMaterialIds', + 'model3dUvConfig', +] as const + +const INSTALLATION_FIELD_KEYS = ['installMethod', 'installSteps'] as const +const PACKAGING_FIELD_KEYS = ['packageDetail', 'craftDetail'] as const + +const PRODUCT_DATA_STYLE_DETAIL_FIELD_KEYS = [ + 'model3dFileName', + 'model3dDataUrl', + 'model3dNodeOffsets', + 'model3dNodeRotations', + 'model3dNodeMaterialIds', + 'model3dUvConfig', +] as const + +const INSTALLATION_STYLE_DETAIL_FIELD_KEYS = [ + 'installMethod', + 'installSteps', +] as const + +const PACKAGING_STYLE_DETAIL_FIELD_KEYS = [ + 'packageDetail', + 'craftDetail', + 'outerPackagingType', + 'innerPackagingItems', + 'packagingModel3dFileName', + 'packagingModel3dDataUrl', + 'packagingModel3dNodeOffsets', + 'packagingModel3dNodeRotations', + 'packagingModel3dNodeMaterialIds', + 'packagingModel3dUvConfig', +] as const + +const PRODUCT_FACTORY_SKU_SYNC_FIELD_KEYS = [ + 'setCount', + 'manufacturer', + 'manufacturerCode', + 'barcode', + 'installMethod', +] as const + +type PreserveWorkspaceCategoryOptions = { + allowProductFactorySkuSync?: boolean +} + +function getSourcePayloadValue( + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, + key: string, +) { + const payloadRecord = isRecord(sourcePayload) ? sourcePayload : {} + return Object.prototype.hasOwnProperty.call(payloadRecord, key) + ? payloadRecord[key] + : fallbackProduct[key] +} + +function getStyleDetailStorageKey(detail: unknown, index: number) { + if (!isRecord(detail)) return `index:${index}` + const styleKey = typeof detail.styleKey === 'string' ? detail.styleKey.trim() : '' + return styleKey || `index:${index}` +} + +function mergeStyleDetailFieldsFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + sourceProduct: SharedNonIntegratedProductRecord, + keys: readonly string[], +) { + const nextDetails = Array.isArray(nextProduct.styleDetails) + ? nextProduct.styleDetails + : [] + const sourceDetails = Array.isArray(sourceProduct.styleDetails) + ? sourceProduct.styleDetails + : [] + const nextByKey = new Map( + nextDetails.map((detail, index) => [getStyleDetailStorageKey(detail, index), detail]), + ) + const sourceByKey = new Map( + sourceDetails.map((detail, index) => [getStyleDetailStorageKey(detail, index), detail]), + ) + const detailKeys = Array.from(new Set([...nextByKey.keys(), ...sourceByKey.keys()])) + + return { + ...nextProduct, + styleDetails: detailKeys.map((detailKey) => { + const nextDetail = nextByKey.get(detailKey) + const sourceDetail = sourceByKey.get(detailKey) + if (!isRecord(sourceDetail)) return nextDetail + const baseDetail = isRecord(nextDetail) ? nextDetail : sourceDetail + return { + ...baseDetail, + ...Object.fromEntries(keys.map((key) => [key, sourceDetail[key]])), + } + }), + } as SharedNonIntegratedProductRecord +} + +function mergeProductFactorySkuFields( + nextSkuItems: unknown, + sourceSkuItems: unknown, +) { + if (!Array.isArray(sourceSkuItems)) return sourceSkuItems + if (!Array.isArray(nextSkuItems)) return sourceSkuItems + + const nextByKey = new Map>() + nextSkuItems.forEach((sku, index) => { + if (!isRecord(sku)) return + const keys = [ + typeof sku.id === 'string' ? sku.id : '', + typeof sku.skuCode === 'string' ? sku.skuCode : '', + typeof sku.barcode === 'string' ? sku.barcode : '', + `index:${index}`, + ].filter(Boolean) + keys.forEach((key) => nextByKey.set(key, sku)) + }) + + return sourceSkuItems.map((sku, index) => { + if (!isRecord(sku)) return sku + const matchedNext = + (typeof sku.id === 'string' ? nextByKey.get(sku.id) : undefined) ?? + (typeof sku.skuCode === 'string' ? nextByKey.get(sku.skuCode) : undefined) ?? + (typeof sku.barcode === 'string' ? nextByKey.get(sku.barcode) : undefined) ?? + nextByKey.get(`index:${index}`) + if (!matchedNext) return sku + + return { + ...sku, + ...Object.fromEntries( + PRODUCT_FACTORY_SKU_SYNC_FIELD_KEYS.map((key) => [key, matchedNext[key]]), + ), + } + }) +} + +function preserveBasicInfoFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + sourceProduct: SharedNonIntegratedProductRecord, + options: PreserveWorkspaceCategoryOptions, +) { + const preservedProduct = preserveFieldsFromProduct( + nextProduct, + sourceProduct, + BASIC_INFO_FIELD_KEYS, + ) + + if (!options.allowProductFactorySkuSync) return preservedProduct + + return { + ...preservedProduct, + skuItems: mergeProductFactorySkuFields(nextProduct.skuItems, sourceProduct.skuItems), + } as SharedNonIntegratedProductRecord +} + +function preserveBasicInfoFromPayload( + nextProduct: SharedNonIntegratedProductRecord, + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, + options: PreserveWorkspaceCategoryOptions, +) { + const preservedProduct = preserveFieldsFromPayload( + nextProduct, + sourcePayload, + fallbackProduct, + BASIC_INFO_FIELD_KEYS, + ) + + if (!options.allowProductFactorySkuSync) return preservedProduct + + return { + ...preservedProduct, + skuItems: mergeProductFactorySkuFields( + nextProduct.skuItems, + getSourcePayloadValue(sourcePayload, fallbackProduct, 'skuItems'), + ), + } as SharedNonIntegratedProductRecord +} + +function preserveDocumentWorkspaceCategoryFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + sourceProduct: SharedNonIntegratedProductRecord, +) { + const documentWorkspaceConfirmations = { ...nextProduct.documentWorkspaceConfirmations } + const documentWorkspaceApprovedAt = { ...nextProduct.documentWorkspaceApprovedAt } + const documentWorkspaceNotes = { ...(nextProduct.documentWorkspaceNotes ?? {}) } + + documentWorkspaceConfirmations[category] = + sourceProduct.documentWorkspaceConfirmations[category] + if (sourceProduct.documentWorkspaceApprovedAt?.[category]) { + documentWorkspaceApprovedAt[category] = sourceProduct.documentWorkspaceApprovedAt[category] + } else { + delete documentWorkspaceApprovedAt[category] + } + + if (sourceProduct.documentWorkspaceNotes?.[category]) { + documentWorkspaceNotes[category] = sourceProduct.documentWorkspaceNotes[category] + } else { + delete documentWorkspaceNotes[category] + } + + return { + ...nextProduct, + documentWorkspaceConfirmations, + documentWorkspaceApprovedAt, + hasPendingDocumentWorkspaceConfirmation: + hasPendingDocumentWorkspaceConfirmation(documentWorkspaceConfirmations), + documentWorkspaceNotes: + Object.keys(documentWorkspaceNotes).length > 0 ? documentWorkspaceNotes : undefined, + } as SharedNonIntegratedProductRecord +} + +function preserveDocumentWorkspaceCategoryFromPayload( + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, +) { + const payloadRecord = isRecord(sourcePayload) ? sourcePayload : {} + const payloadConfirmations = Object.prototype.hasOwnProperty.call( + payloadRecord, + 'documentWorkspaceConfirmations', + ) + ? normalizeDocumentWorkspaceConfirmations(payloadRecord.documentWorkspaceConfirmations) + : fallbackProduct.documentWorkspaceConfirmations + const payloadApprovedAt = Object.prototype.hasOwnProperty.call( + payloadRecord, + 'documentWorkspaceApprovedAt', + ) + ? normalizeDocumentWorkspaceApprovedAt(payloadRecord.documentWorkspaceApprovedAt) + : fallbackProduct.documentWorkspaceApprovedAt + const payloadNotes = Object.prototype.hasOwnProperty.call( + payloadRecord, + 'documentWorkspaceNotes', + ) + ? normalizeDocumentWorkspaceNotes(payloadRecord.documentWorkspaceNotes) + : fallbackProduct.documentWorkspaceNotes + const documentWorkspaceConfirmations = { ...nextProduct.documentWorkspaceConfirmations } + const documentWorkspaceApprovedAt = { ...nextProduct.documentWorkspaceApprovedAt } + const documentWorkspaceNotes = { ...(nextProduct.documentWorkspaceNotes ?? {}) } + + documentWorkspaceConfirmations[category] = payloadConfirmations[category] + if (payloadApprovedAt?.[category]) { + documentWorkspaceApprovedAt[category] = payloadApprovedAt[category] + } else { + delete documentWorkspaceApprovedAt[category] + } + + if (payloadNotes?.[category]) { + documentWorkspaceNotes[category] = payloadNotes[category] + } else { + delete documentWorkspaceNotes[category] + } + + return { + ...nextProduct, + documentWorkspaceConfirmations, + documentWorkspaceApprovedAt, + hasPendingDocumentWorkspaceConfirmation: + hasPendingDocumentWorkspaceConfirmation(documentWorkspaceConfirmations), + documentWorkspaceNotes: + Object.keys(documentWorkspaceNotes).length > 0 ? documentWorkspaceNotes : undefined, + } as SharedNonIntegratedProductRecord +} + +function isUploadFileInWorkspaceCategory( + file: SharedNonIntegratedUploadFile, + category: NonIntegratedWorkspaceCategoryKey, +) { + const fileCategory = toDocumentWorkspaceCategory(file.category) + return fileCategory === category || (!fileCategory && category === PRODUCT_DATA_WORKSPACE_CATEGORY) +} + +function preserveUploadFilesForCategoryFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + sourceProduct: SharedNonIntegratedProductRecord, +) { + return { + ...nextProduct, + uploadFiles: [ + ...nextProduct.uploadFiles.filter( + (file) => !isUploadFileInWorkspaceCategory(file, category), + ), + ...sourceProduct.uploadFiles.filter((file) => + isUploadFileInWorkspaceCategory(file, category), + ), + ], + } as SharedNonIntegratedProductRecord +} + +function preserveUploadFilesForCategoryFromPayload( + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, +) { + const sourceUploadFiles = mergeUploadFileMetadata( + fallbackProduct.uploadFiles, + getSourcePayloadValue(sourcePayload, fallbackProduct, 'uploadFiles'), + getSourcePayloadValue(sourcePayload, fallbackProduct, 'uploadFiles'), + ) + + return { + ...nextProduct, + uploadFiles: [ + ...nextProduct.uploadFiles.filter( + (file) => !isUploadFileInWorkspaceCategory(file, category), + ), + ...sourceUploadFiles.filter((file) => + isUploadFileInWorkspaceCategory(file, category), + ), + ], + } as SharedNonIntegratedProductRecord +} + +function preserveWorkspaceCategoryFromProduct( + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + sourceProduct: SharedNonIntegratedProductRecord, + options: PreserveWorkspaceCategoryOptions, +) { + let preservedProduct = preserveUploadFilesForCategoryFromProduct( + preserveDocumentWorkspaceCategoryFromProduct(nextProduct, category, sourceProduct), + category, + sourceProduct, + ) + + switch (category) { + case NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY: + return preserveBasicInfoFromProduct(preservedProduct, sourceProduct, options) + case OPENING_REFERENCE_WORKSPACE_CATEGORY: + return preserveOpeningReferenceFromProduct(preservedProduct, sourceProduct) + case PROJECT_REVIEW_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + projectReview: sourceProduct.projectReview, + projectStatus: sourceProduct.projectStatus, + } as SharedNonIntegratedProductRecord + case REVIEW_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + reviewSheet: sourceProduct.reviewSheet, + reviewSheets: sourceProduct.reviewSheets, + } as SharedNonIntegratedProductRecord + case PATENT_APPLICATION_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + patentApplication: sourceProduct.patentApplication, + } as SharedNonIntegratedProductRecord + case PRODUCT_DATA_WORKSPACE_CATEGORY: + preservedProduct = preserveFieldsFromProduct( + preservedProduct, + sourceProduct, + PRODUCT_DATA_FIELD_KEYS, + ) + return mergeStyleDetailFieldsFromProduct( + preservedProduct, + sourceProduct, + PRODUCT_DATA_STYLE_DETAIL_FIELD_KEYS, + ) + case INSTALLATION_WORKSPACE_CATEGORY: + preservedProduct = preserveFieldsFromProduct( + preservedProduct, + sourceProduct, + INSTALLATION_FIELD_KEYS, + ) + return mergeStyleDetailFieldsFromProduct( + preservedProduct, + sourceProduct, + INSTALLATION_STYLE_DETAIL_FIELD_KEYS, + ) + case PACKAGING_WORKSPACE_CATEGORY: + preservedProduct = preserveFieldsFromProduct( + preservedProduct, + sourceProduct, + PACKAGING_FIELD_KEYS, + ) + return mergeStyleDetailFieldsFromProduct( + preservedProduct, + sourceProduct, + PACKAGING_STYLE_DETAIL_FIELD_KEYS, + ) + case PRODUCT_FACTORY_WORKSPACE_CATEGORY: + return { ...preservedProduct, factoryRows: sourceProduct.factoryRows } + case SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + sampleMeasurementRows: sourceProduct.sampleMeasurementRows, + } as SharedNonIntegratedProductRecord + case FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + finishedSampleShipmentRows: sourceProduct.finishedSampleShipmentRows, + } as SharedNonIntegratedProductRecord + case PRODUCT_PRICING_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + pricingRows: sourceProduct.pricingRows, + bomDetails: sourceProduct.bomDetails, + } as SharedNonIntegratedProductRecord + case PRODUCT_LIMIT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + productLimitRows: sourceProduct.productLimitRows, + } as SharedNonIntegratedProductRecord + case PLANNING_LAYOUT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + planningLayoutRows: sourceProduct.planningLayoutRows, + } as SharedNonIntegratedProductRecord + case FACTORY_QUALITY_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + qualityInspectionSheet: sourceProduct.qualityInspectionSheet, + firstQualityRows: sourceProduct.firstQualityRows, + } as SharedNonIntegratedProductRecord + default: + return preservedProduct + } +} + +function preserveWorkspaceCategoryFromPayload( + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + sourcePayload: unknown, + fallbackProduct: SharedNonIntegratedProductRecord, + options: PreserveWorkspaceCategoryOptions, +) { + let preservedProduct = preserveUploadFilesForCategoryFromPayload( + preserveDocumentWorkspaceCategoryFromPayload( + nextProduct, + category, + sourcePayload, + fallbackProduct, + ), + category, + sourcePayload, + fallbackProduct, + ) + + switch (category) { + case NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY: + return preserveBasicInfoFromPayload( + preservedProduct, + sourcePayload, + fallbackProduct, + options, + ) + case OPENING_REFERENCE_WORKSPACE_CATEGORY: + return preserveOpeningReferenceFromPayload( + preservedProduct, + sourcePayload, + fallbackProduct, + ) + case PROJECT_REVIEW_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + projectReview: getSourcePayloadValue(sourcePayload, fallbackProduct, 'projectReview'), + projectStatus: getSourcePayloadValue(sourcePayload, fallbackProduct, 'projectStatus'), + } as SharedNonIntegratedProductRecord + case REVIEW_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + reviewSheet: getSourcePayloadValue(sourcePayload, fallbackProduct, 'reviewSheet'), + reviewSheets: getSourcePayloadValue(sourcePayload, fallbackProduct, 'reviewSheets'), + } as SharedNonIntegratedProductRecord + case PATENT_APPLICATION_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + patentApplication: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'patentApplication', + ), + } as SharedNonIntegratedProductRecord + case PRODUCT_DATA_WORKSPACE_CATEGORY: + preservedProduct = preserveFieldsFromPayload( + preservedProduct, + sourcePayload, + fallbackProduct, + PRODUCT_DATA_FIELD_KEYS, + ) + return mergeStyleDetailFieldsFromProduct( + preservedProduct, + fallbackProduct, + PRODUCT_DATA_STYLE_DETAIL_FIELD_KEYS, + ) + case INSTALLATION_WORKSPACE_CATEGORY: + preservedProduct = preserveFieldsFromPayload( + preservedProduct, + sourcePayload, + fallbackProduct, + INSTALLATION_FIELD_KEYS, + ) + return mergeStyleDetailFieldsFromProduct( + preservedProduct, + fallbackProduct, + INSTALLATION_STYLE_DETAIL_FIELD_KEYS, + ) + case PACKAGING_WORKSPACE_CATEGORY: + preservedProduct = preserveFieldsFromPayload( + preservedProduct, + sourcePayload, + fallbackProduct, + PACKAGING_FIELD_KEYS, + ) + return mergeStyleDetailFieldsFromProduct( + preservedProduct, + fallbackProduct, + PACKAGING_STYLE_DETAIL_FIELD_KEYS, + ) + case PRODUCT_FACTORY_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + factoryRows: getSourcePayloadValue(sourcePayload, fallbackProduct, 'factoryRows'), + } as SharedNonIntegratedProductRecord + case SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + sampleMeasurementRows: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'sampleMeasurementRows', + ), + } as SharedNonIntegratedProductRecord + case FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + finishedSampleShipmentRows: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'finishedSampleShipmentRows', + ), + } as SharedNonIntegratedProductRecord + case PRODUCT_PRICING_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + pricingRows: getSourcePayloadValue(sourcePayload, fallbackProduct, 'pricingRows'), + bomDetails: getSourcePayloadValue(sourcePayload, fallbackProduct, 'bomDetails'), + } as SharedNonIntegratedProductRecord + case PRODUCT_LIMIT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + productLimitRows: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'productLimitRows', + ), + } as SharedNonIntegratedProductRecord + case PLANNING_LAYOUT_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + planningLayoutRows: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'planningLayoutRows', + ), + } as SharedNonIntegratedProductRecord + case FACTORY_QUALITY_WORKSPACE_CATEGORY: + return { + ...preservedProduct, + qualityInspectionSheet: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'qualityInspectionSheet', + ), + firstQualityRows: getSourcePayloadValue( + sourcePayload, + fallbackProduct, + 'firstQualityRows', + ), + } as SharedNonIntegratedProductRecord + default: + return preservedProduct + } +} + function styleDetailsByStyleKey(product: SharedNonIntegratedProductRecord) { return Object.fromEntries( (product.styleDetails ?? []).map((detail) => [detail.styleKey, detail]), @@ -591,11 +1275,88 @@ function getChangedWorkspaceCategories( return categories } -async function assertCanWriteProductChanges( +function getChangedWorkspaceCategoriesAfterPreservingCategory( + existingProduct: SharedNonIntegratedProductRecord, + nextProduct: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, + options: PreserveWorkspaceCategoryOptions, +) { + return getChangedWorkspaceCategories( + existingProduct, + preserveWorkspaceCategoryFromProduct( + nextProduct, + category, + existingProduct, + options, + ), + ) +} + +function hasAtLeastOnePermittedChangedCategory( + existingProduct: SharedNonIntegratedProductRecord, + nextProduct: SharedNonIntegratedProductRecord, + changedCategories: Set, + permittedCategories: Set, + options: PreserveWorkspaceCategoryOptions, +) { + for (const category of permittedCategories) { + if (!changedCategories.has(category)) continue + + const categoriesWithoutCurrent = getChangedWorkspaceCategoriesAfterPreservingCategory( + existingProduct, + nextProduct, + category, + options, + ) + if (categoriesWithoutCurrent.has(category)) continue + + return true + } + + return false +} + +function getRequestedWritableCategories( + requestedCategory: string | null | undefined, + fallbackCategories: Set, +) { + const category = toDocumentWorkspaceCategory(requestedCategory ?? undefined) + return category ? new Set([category]) : fallbackCategories +} + +function throwNonIntegratedWriteDenied(): never { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限编辑这条非集成产品记录') +} + +function isStockRecordPayload(record: IntakeNonIntegratedRecord) { + const product = buildHydratedProductRecord(record.payload, { + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }) + + return product ? isStockNonIntegratedProduct(product) : false +} + +function assertStockWorkspaceWriteCategory( + record: IntakeNonIntegratedRecord, + category?: string | null, +) { + if (!isStockRecordPayload(record)) return + + const resolvedCategory = toDocumentWorkspaceCategory(category ?? undefined) + if (resolvedCategory === PLANNING_LAYOUT_WORKSPACE_CATEGORY) return + + throwNonIntegratedWriteDenied() +} + +async function prepareProductForPermittedWrite( record: IntakeNonIntegratedRecord, user: AuthUser, nextProduct: SharedNonIntegratedProductRecord, -) { + options: SaveNonIntegratedProductOptions = {}, +): Promise { const existingProduct = buildHydratedProductRecord(record.payload, { createdAt: record.createdAt, updatedAt: record.updatedAt, @@ -605,19 +1366,137 @@ async function assertCanWriteProductChanges( if (!existingProduct) { await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) - return + return { product: nextProduct } } + const isStockProduct = isStockNonIntegratedProduct(existingProduct) const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProduct) if (changedCategories.size === 0) { + if (isStockProduct) { + if (!intakeNonIntegratedScopeService.canReadRecord(record, user)) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') + } + + return { + product: existingProduct, + preserveRecordColumns: true, + skipUpdate: true, + } + } + await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) - return + return { product: nextProduct } } + const writableCategories = new Set() + for (const category of TRACKED_WORKSPACE_CATEGORIES) { + if (isStockProduct && category !== PLANNING_LAYOUT_WORKSPACE_CATEGORY) { + continue + } + + if ( + await intakeNonIntegratedScopeService.canWriteWorkspaceRecordWithSettings( + record, + user, + category, + ) + ) { + writableCategories.add(category) + } + } + const requestedWritableCategories = getRequestedWritableCategories( + options.workspaceCategory, + writableCategories, + ) + + const deniedChangedCategories: NonIntegratedWorkspaceCategoryKey[] = [] + const permittedCategories = new Set() for (const category of changedCategories) { + if (writableCategories.has(category) && requestedWritableCategories.has(category)) { + permittedCategories.add(category) + continue + } + + deniedChangedCategories.push(category) + } + + const preserveOptions = { + allowProductFactorySkuSync: + !isStockProduct && changedCategories.has(PRODUCT_FACTORY_WORKSPACE_CATEGORY), + } + if ( + deniedChangedCategories.length > 0 && + !hasAtLeastOnePermittedChangedCategory( + existingProduct, + nextProduct, + changedCategories, + permittedCategories, + preserveOptions, + ) + ) { + if (isStockProduct) { + throwNonIntegratedWriteDenied() + } + + await intakeNonIntegratedScopeService.assertCanWriteRecord( + record, + user, + deniedChangedCategories[0], + ) + return { product: nextProduct } + } + + const categoriesToPreserve = TRACKED_WORKSPACE_CATEGORIES.filter( + (category) => + !writableCategories.has(category) || + !requestedWritableCategories.has(category) || + !changedCategories.has(category), + ) + const scopedNextProduct = categoriesToPreserve.reduce( + (currentProduct, category) => + preserveWorkspaceCategoryFromProduct( + currentProduct, + category, + existingProduct, + preserveOptions, + ), + nextProduct, + ) + const scopedChangedCategories = getChangedWorkspaceCategories( + existingProduct, + scopedNextProduct, + ) + + if (scopedChangedCategories.size === 0) { + if (isStockProduct) { + throwNonIntegratedWriteDenied() + } + + await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) + return { product: scopedNextProduct } + } + + for (const category of scopedChangedCategories) { await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category) } + + return { + product: TRACKED_WORKSPACE_CATEGORIES.reduce( + (currentProduct, category) => + categoriesToPreserve.includes(category) + ? preserveWorkspaceCategoryFromPayload( + currentProduct, + category, + record.payload, + existingProduct, + preserveOptions, + ) + : currentProduct, + scopedNextProduct, + ), + preserveRecordColumns: true, + } } function normalizePresentationPreview( @@ -1445,6 +2324,19 @@ function isSpuUniqueConstraintError(error: unknown) { return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' } +async function assertNoDuplicateProduct( + repository: IntakeNonIntegratedProductRepositoryClass, + product: DuplicateProductCheckInput, +) { + const duplicateRecord = await repository.findBySpu(product.spu, product.id, product.brand) + if (duplicateRecord) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, + ) + } +} + class IntakeNonIntegratedProductService { private readonly repository: IntakeNonIntegratedProductRepositoryClass = intakeNonIntegratedProductRepository @@ -1545,6 +2437,7 @@ class IntakeNonIntegratedProductService { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') } + assertStockWorkspaceWriteCategory(existingRecord, category) await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, category) } @@ -1572,7 +2465,11 @@ class IntakeNonIntegratedProductService { ) } - async save(productInput: unknown, user: AuthUser) { + async save( + productInput: unknown, + user: AuthUser, + options: SaveNonIntegratedProductOptions = {}, + ) { const existingRecordCandidate = isRecord(productInput) && typeof productInput.id === 'string' ? await this.repository.findById(productInput.id) : null @@ -1588,19 +2485,6 @@ class IntakeNonIntegratedProductService { const normalizedSpu = resolveNonIntegratedPersistedSpu(normalizedProduct) - const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu) - const duplicateRecord = await this.repository.findBySpu( - normalizedSpu, - normalizedProduct.id, - normalizedProduct.brand, - ) - if (duplicateRecord) { - throw new AppError( - ErrorCode.VALIDATION_FAILED, - NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, - ) - } - const deletedRecordToRestore = existingRecordCandidate ? null : await this.repository.findDeletedBySpu( @@ -1636,28 +2520,62 @@ class IntakeNonIntegratedProductService { try { if (existingRecord) { - await assertCanWriteProductChanges(existingRecord, user, persistedProduct) - - await this.repository.update(existingRecord.id, { - spu: normalizedSpu, - spuBase: normalizedSpuBase, - brand: persistedProduct.brand ?? '', - designer: persistedProduct.designer, - operator: persistedProduct.operator, - purchaser: persistedProduct.purchaser, - payload: toJsonValue(persistedProduct), - updatedBy: user.id, - deletedAt: null, - ...(existingRecord.createdBy === user.id && - !existingRecord.departmentId && - scopedDepartmentId - ? { departmentId: scopedDepartmentId } - : {}), - ...(existingRecord.createdBy === user.id && !existingRecord.teamId && scopedTeamId - ? { teamId: scopedTeamId } - : {}), + const permittedWrite = await prepareProductForPermittedWrite( + existingRecord, + user, + persistedProduct, + options, + ) + const permittedProduct = permittedWrite.product + const permittedSpu = resolveNonIntegratedPersistedSpu(permittedProduct) + const permittedSpuBase = stripNonIntegratedBrandPrefix(permittedSpu) + await assertNoDuplicateProduct(this.repository, { + id: permittedProduct.id, + spu: permittedSpu, + brand: permittedWrite.preserveRecordColumns + ? existingRecord.brand + : permittedProduct.brand, }) + + if (!permittedWrite.skipUpdate) { + await this.repository.update(existingRecord.id, { + spu: permittedWrite.preserveRecordColumns ? existingRecord.spu : permittedSpu, + spuBase: permittedWrite.preserveRecordColumns + ? existingRecord.spuBase + : permittedSpuBase, + brand: permittedWrite.preserveRecordColumns + ? existingRecord.brand + : permittedProduct.brand ?? '', + designer: permittedWrite.preserveRecordColumns + ? existingRecord.designer + : permittedProduct.designer, + operator: permittedWrite.preserveRecordColumns + ? existingRecord.operator + : permittedProduct.operator, + purchaser: permittedWrite.preserveRecordColumns + ? existingRecord.purchaser + : permittedProduct.purchaser, + payload: toJsonValue(permittedProduct), + updatedBy: user.id, + deletedAt: null, + ...(existingRecord.createdBy === user.id && + !existingRecord.departmentId && + scopedDepartmentId + ? { departmentId: scopedDepartmentId } + : {}), + ...(existingRecord.createdBy === user.id && !existingRecord.teamId && scopedTeamId + ? { teamId: scopedTeamId } + : {}), + }) + } } else if (deletedRecordToRestore) { + const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu) + await assertNoDuplicateProduct(this.repository, { + id: normalizedProduct.id, + spu: normalizedSpu, + brand: normalizedProduct.brand, + }) + await this.repository.update(deletedRecordToRestore.id, { spu: normalizedSpu, spuBase: normalizedSpuBase, @@ -1675,6 +2593,13 @@ class IntakeNonIntegratedProductService { deletedAt: null, }) } else { + const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu) + await assertNoDuplicateProduct(this.repository, { + id: normalizedProduct.id, + spu: normalizedSpu, + brand: normalizedProduct.brand, + }) + await this.repository.create({ id: persistedProduct.id, spu: normalizedSpu, @@ -1703,7 +2628,10 @@ class IntakeNonIntegratedProductService { throw error } - const savedProduct = await this.getById(persistedProduct.id, user) + const savedProduct = await this.getById( + existingRecord?.id ?? persistedProduct.id, + user, + ) if (!savedProduct) { throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品保存后读取失败') } @@ -1731,6 +2659,7 @@ class IntakeNonIntegratedProductService { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') } + assertStockWorkspaceWriteCategory(existingRecord, input.category) await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, input.category) const payload = buildHydratedProductRecord(existingRecord.payload, { @@ -1852,6 +2781,7 @@ class IntakeNonIntegratedProductService { user: AuthUser, ) { const { record, product, file } = await this.resolveUploadFileForPreview(productId, input, user) + assertStockWorkspaceWriteCategory(record, file.category) await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, file.category) const nextUploadFiles = product.uploadFiles.map((file) => From 6d6e4b299d0d9ae47f744ddeac79957b9ad48650 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 28 May 2026 11:02:33 +0800 Subject: [PATCH 7/8] fix(intake): allow stock planning layout save noops --- ...ake-non-integrated-product.service.test.ts | 87 +++++++++++++++++++ .../intake-non-integrated-product.service.ts | 29 +++++++ 2 files changed, 116 insertions(+) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index 0272a48c..e4f4fb8e 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -955,6 +955,93 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).not.toHaveBeenCalled() }) + it('treats stock planning layout manual saves with only readonly legacy differences as successful no-ops', async () => { + const existingPayload = createPayload({ + id: 'stock-planning-layout-manual-save', + spu: 'GBPM-AD0302', + productCode: 'GBPM-AD0302', + processTemplate: '存量商品', + brand: '基本盘', + adaptableBrands: ['基本盘'], + openingReference: { + brand: '基本盘', + adaptableBrands: ['基本盘'], + currentStage: '存量商品', + }, + patentApplication: { + patentName: '', + }, + planningLayoutRows: [ + { + skuId: 'stock-sku-1', + styles: ['中古风'], + styleTones: [], + audiences: [], + spaces: ['餐厅'], + categories: [], + brands: ['自有品牌-朴美'], + model: '', + priceBand: '', + shop: '', + }, + ], + }) + const incomingPayload = createPayload({ + ...existingPayload, + brand: '其他品牌-普通大货', + adaptableBrands: ['其他品牌-普通大货'], + openingReference: { + ...(existingPayload.openingReference as Record), + brand: '其他品牌-普通大货', + adaptableBrands: ['其他品牌-普通大货'], + }, + patentApplication: { + ...(existingPayload.patentApplication as Record), + patentName: '不应写入的只读差异', + }, + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + id: 'stock-planning-layout-manual-save', + spu: 'GBPM-AD0302', + payload: existingPayload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'stock-planning-layout-manual-save', + spu: 'GBPM-AD0302', + payload: existingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save( + incomingPayload, + { + ...baseUser, + id: 'space-visual-1', + email: 'space@example.com', + name: '希文', + businessRoles: ['PLANNING'] as const, + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + }, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + ], + }, + { workspaceCategory: '企划布局' }, + ), + ).resolves.toEqual(expect.objectContaining({ id: 'stock-planning-layout-manual-save' })) + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + it('keeps existing BOM details when an autosave submits an empty BOM payload', async () => { const existingBomDetails = [ { diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 145fe72b..b38c21cd 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -1324,6 +1324,13 @@ function getRequestedWritableCategories( return category ? new Set([category]) : fallbackCategories } +function isExplicitPlanningLayoutWriteRequest( + requestedCategory: string | null | undefined, +) { + return toDocumentWorkspaceCategory(requestedCategory ?? undefined) === + PLANNING_LAYOUT_WORKSPACE_CATEGORY +} + function throwNonIntegratedWriteDenied(): never { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限编辑这条非集成产品记录') } @@ -1436,6 +1443,17 @@ async function prepareProductForPermittedWrite( ) ) { if (isStockProduct) { + if ( + isExplicitPlanningLayoutWriteRequest(options.workspaceCategory) && + writableCategories.has(PLANNING_LAYOUT_WORKSPACE_CATEGORY) + ) { + return { + product: existingProduct, + preserveRecordColumns: true, + skipUpdate: true, + } + } + throwNonIntegratedWriteDenied() } @@ -1470,6 +1488,17 @@ async function prepareProductForPermittedWrite( if (scopedChangedCategories.size === 0) { if (isStockProduct) { + if ( + isExplicitPlanningLayoutWriteRequest(options.workspaceCategory) && + writableCategories.has(PLANNING_LAYOUT_WORKSPACE_CATEGORY) + ) { + return { + product: existingProduct, + preserveRecordColumns: true, + skipUpdate: true, + } + } + throwNonIntegratedWriteDenied() } From 46c0098ae3c483b13d250e57bb9793427eacc788 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 28 May 2026 11:12:09 +0800 Subject: [PATCH 8/8] fix(intake): allow planning layout save noops --- ...ake-non-integrated-product.service.test.ts | 85 +++++++++++++++++++ .../intake-non-integrated-product.service.ts | 54 +++++++----- 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index e4f4fb8e..93586d8a 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -608,6 +608,91 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).toHaveBeenCalled() }) + it('treats normal planning layout manual saves with only readonly differences as successful no-ops', async () => { + const existingPayload = createPayload({ + id: 'normal-planning-layout-manual-save', + spu: 'GBGJ-BJ0191', + processTemplate: '普通产品', + brand: '代理品牌-顾家', + productLimitRows: [ + { + skuId: 'sku-1', + expectedDailyLimitPrice: '599', + expectedLimitGrossMarginRate: '', + nonSpuOwnerLimitPrice: '', + }, + ], + planningLayoutRows: [ + { + skuId: 'sku-1', + styles: ['中古风'], + styleTones: ['浅色系'], + audiences: ['年轻家庭'], + spaces: ['餐厅'], + categories: [], + brands: ['代理品牌-顾家'], + model: '', + priceBand: '', + shop: '', + }, + ], + }) + const incomingPayload = createPayload({ + ...existingPayload, + productLimitRows: [ + { + skuId: 'sku-1', + expectedDailyLimitPrice: '699', + expectedLimitGrossMarginRate: '', + nonSpuOwnerLimitPrice: '', + }, + ], + planningLayoutRows: existingPayload.planningLayoutRows, + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + id: 'normal-planning-layout-manual-save', + spu: 'GBGJ-BJ0191', + payload: existingPayload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'normal-planning-layout-manual-save', + spu: 'GBGJ-BJ0191', + payload: existingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save( + incomingPayload, + { + ...baseUser, + id: 'space-visual-1', + email: 'space@example.com', + name: '希文', + businessRoles: ['PLANNING'] as const, + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + }, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + ], + }, + { workspaceCategory: '企划布局' }, + ), + ).resolves.toEqual( + expect.objectContaining({ id: 'normal-planning-layout-manual-save' }), + ) + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + it('allows space visual users to save stock planning layout without rewriting readonly legacy fields', async () => { const stockSku = { id: 'jst-stock-sku-4fe2f2b80cfa6ad08100', diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index b38c21cd..8bf4360c 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -1331,6 +1331,16 @@ function isExplicitPlanningLayoutWriteRequest( PLANNING_LAYOUT_WORKSPACE_CATEGORY } +function canTreatPlanningLayoutSaveAsNoop( + requestedCategory: string | null | undefined, + writableCategories: Set, +) { + return ( + isExplicitPlanningLayoutWriteRequest(requestedCategory) && + writableCategories.has(PLANNING_LAYOUT_WORKSPACE_CATEGORY) + ) +} + function throwNonIntegratedWriteDenied(): never { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限编辑这条非集成产品记录') } @@ -1442,18 +1452,20 @@ async function prepareProductForPermittedWrite( preserveOptions, ) ) { - if (isStockProduct) { - if ( - isExplicitPlanningLayoutWriteRequest(options.workspaceCategory) && - writableCategories.has(PLANNING_LAYOUT_WORKSPACE_CATEGORY) - ) { - return { - product: existingProduct, - preserveRecordColumns: true, - skipUpdate: true, - } + if ( + canTreatPlanningLayoutSaveAsNoop( + options.workspaceCategory, + writableCategories, + ) + ) { + return { + product: existingProduct, + preserveRecordColumns: true, + skipUpdate: true, } + } + if (isStockProduct) { throwNonIntegratedWriteDenied() } @@ -1487,18 +1499,20 @@ async function prepareProductForPermittedWrite( ) if (scopedChangedCategories.size === 0) { - if (isStockProduct) { - if ( - isExplicitPlanningLayoutWriteRequest(options.workspaceCategory) && - writableCategories.has(PLANNING_LAYOUT_WORKSPACE_CATEGORY) - ) { - return { - product: existingProduct, - preserveRecordColumns: true, - skipUpdate: true, - } + if ( + canTreatPlanningLayoutSaveAsNoop( + options.workspaceCategory, + writableCategories, + ) + ) { + return { + product: existingProduct, + preserveRecordColumns: true, + skipUpdate: true, } + } + if (isStockProduct) { throwNonIntegratedWriteDenied() }
- {item.imageUrl ? ( + {displayImageUrl ? ( {item.drive} - {item.tail} - + {item.tail} + {item.material} - {item.finish} - - {item.tooth} - + {item.finish} + + {item.tooth} + {item.diameter} - {item.length} - + {item.length} + {renderTagList(item.usages)} - {item.color} - - {item.preDrill} - + {item.color} + + {item.preDrill} + {renderTagList(item.environments, 2)}
- {previewItem?.imageUrl ? ( + {previewItem && getDisplayImageUrl(previewItem) ? (
{previewItem.name}

- {previewItem.imageName || `${previewItem.code} 图片`} + {getDisplayImageName(previewItem)}

) : null} diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx index 93e0b507..e972ea78 100644 --- a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx @@ -66,10 +66,10 @@ describe('alphabet library panels', () => { render() - await actor.click(screen.getByRole('button', { name: 'D 0 条' })) + await actor.click(screen.getByRole('button', { name: 'E 0 条' })) - expect(screen.getByText('D 组配件')).toBeInTheDocument() - expect(screen.getByText('D 组暂未配置配件分类')).toBeInTheDocument() + expect(screen.getByText('E 组配件')).toBeInTheDocument() + expect(screen.getByText('E 组暂未配置配件分类')).toBeInTheDocument() expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument() await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' })) @@ -87,6 +87,10 @@ describe('alphabet library panels', () => { expect(screen.getByText('C 组配件 · 连接螺杆')).toBeInTheDocument() expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '全牙螺杆' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '双头螺杆' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '双头牙' })).toBeInTheDocument() await actor.click(screen.getByRole('button', { name: '新增 C 组' })) @@ -135,6 +139,119 @@ describe('alphabet library panels', () => { }) }) + it('filters C and D accessory groups by category shortcuts', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'C 连接螺杆 0 条' })) + await actor.click(screen.getByRole('button', { name: '新增 C 组' })) + let dialog = await screen.findByRole('dialog', { name: '新增C组配件' }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('M8*100 双平尾 全牙螺杆')).toBeInTheDocument() + }) + + await actor.click(screen.getByRole('button', { name: '新增 C 组' })) + dialog = await screen.findByRole('dialog', { name: '新增C组配件' }) + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '双头牙' }, + }) + fireEvent.change(within(dialog).getByLabelText('尺寸'), { + target: { value: '70mm' }, + }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('M8*70 双平尾 双头牙')).toBeInTheDocument() + }) + + await actor.click(screen.getByRole('button', { name: '双头牙' })) + expect(screen.queryByText('M8*100 双平尾 全牙螺杆')).not.toBeInTheDocument() + expect(screen.getByText('M8*70 双平尾 双头牙')).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '全部分类' })) + expect(screen.getByText('M8*100 双平尾 全牙螺杆')).toBeInTheDocument() + expect(screen.getByText('M8*70 双平尾 双头牙')).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: 'D 偏心连接件 0 条' })) + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '三合一连接件' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '二合一连接件' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '四合一连接件' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '隐形连接件' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '双头偏心连接杆' })).toBeInTheDocument() + }) + + it('uses whole-set eccentric connector rules for D accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'D 偏心连接件 0 条' })) + + expect(screen.getByText('D 组配件 · 偏心连接件')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 D 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增D组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'φ15 M6*34 锌合金 三合一连接件', + ) + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument() + expect(within(dialog).getByLabelText('规格')).toBeInTheDocument() + expect(within(dialog).getByLabelText('长度')).toBeInTheDocument() + expect(within(dialog).getByLabelText('材质/表面处理')).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '双头偏心连接杆' }, + }) + fireEvent.change(within(dialog).getByLabelText('规格'), { + target: { value: 'M8' }, + }) + fireEvent.change(within(dialog).getByLabelText('长度'), { + target: { value: '42mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质/表面处理'), { + target: { value: '镀锌' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M8*42 镀锌 双头偏心连接杆', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('M8*42 镀锌 双头偏心连接杆')).toBeInTheDocument() + }) + expect(screen.getAllByText('双头偏心连接杆').length).toBeGreaterThan(0) + expect(screen.getAllByText('42mm').length).toBeGreaterThan(0) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'D', + code: 'D0', + name: 'M8*42 镀锌 双头偏心连接杆', + screwCategory: '双头偏心连接杆', + headType: '', + drive: '', + tail: '', + material: '镀锌', + finish: '', + tooth: '', + diameter: 'M8', + length: '42mm', + }) + }) + it('opens copy-new mode and blocks exact duplicate screw configs', async () => { const actor = userEvent.setup() diff --git a/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx b/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx index cda5454c..1d46ac31 100644 --- a/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, type InputHTMLAttributes } from 'react' import { Copy, Layers3, Plus, Ruler, Save, Search, Trash2 } from 'lucide-react' import Button from '@/components/ui/Button' import { Input } from '@/components/ui/Input' @@ -32,6 +32,26 @@ const EDGE_BANDING_CATEGORY_STORAGE_KEY = 'formal-intake-edge-banding-library-categories' const LIBRARY_PRIMARY_BUTTON_CLASS = '!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400' +const EDGE_BANDING_TOP_FIELD_CLASS = + 'mt-1 h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-xs text-apple-label-primary outline-none placeholder:text-apple-label-tertiary focus:border-apple-blue' + +function EdgeBandingTopField({ + label, + className = '', + ...inputProps +}: InputHTMLAttributes & { + label: string +}) { + return ( + + ) +} function normalizeText(value: string) { return value.trim().toLowerCase() @@ -54,10 +74,42 @@ function parseEdgeBandingThicknessFromSpec(value: string) { return Number.isFinite(parsed) && parsed > 0 ? parsed : null } +function parsePositiveNumberFromText(value: string) { + const matchedText = value.trim().replace(/,/g, '').match(/(\d+(?:\.\d+)?)/)?.[1] + if (!matchedText) return null + + const parsed = Number(matchedText) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function parseEdgeBandingWidthFromSpec(value: string) { + const matchedText = value.trim().match(/[*×xX]\s*(\d+(?:\.\d+)?)/)?.[1] + if (!matchedText) return null + + const parsed = Number(matchedText) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + function getEdgeBandingThicknessTextFromSpec(value: string) { return value.trim().match(/^(\d+(?:\.\d+)?)/)?.[1] ?? '' } +function matchesNumberOrTextQuery( + query: string, + numericValue: number | null, + textValues: string[], +) { + const keyword = normalizeText(query) + if (!keyword) return true + + const queryNumber = parsePositiveNumberFromText(keyword) + if (queryNumber !== null && numericValue !== null) { + return Math.abs(numericValue - queryNumber) < 0.0001 + } + + return textValues.map(normalizeText).some((value) => value.includes(keyword)) +} + function getEdgeBandingBaseName(item: Pick) { const spec = normalizeDisplayText(item.spec) const edgeThickness = getEdgeBandingThicknessTextFromSpec(spec) @@ -174,9 +226,13 @@ export default function EdgeBandingLibraryPanel({ const [items, setItems] = useState(initialItems) const [categories, setCategories] = useState(initialCategories) const [queryName, setQueryName] = useState('') + const [queryEdgeThickness, setQueryEdgeThickness] = useState('') const [queryThickness, setQueryThickness] = useState('') + const [querySpecWidth, setQuerySpecWidth] = useState('') const [appliedQueryName, setAppliedQueryName] = useState('') + const [appliedQueryEdgeThickness, setAppliedQueryEdgeThickness] = useState('') const [appliedQueryThickness, setAppliedQueryThickness] = useState('') + const [appliedQuerySpecWidth, setAppliedQuerySpecWidth] = useState('') const [categoryDraftName, setCategoryDraftName] = useState('') const [selectedItemId, setSelectedItemId] = useState( initialItems[0]?.id ?? null, @@ -208,21 +264,40 @@ export default function EdgeBandingLibraryPanel({ const filteredItems = useMemo(() => { const nameKeyword = normalizeText(appliedQueryName) - const queryThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness) + const queryBoardThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness) return normalizedItems.filter((item) => { + const displayName = buildEdgeBandingDisplayName(item) + const baseName = getEdgeBandingBaseName(item) const matchesName = !nameKeyword || - [item.name, item.spec, buildEdgeBandingDisplayName(item)] + [item.name, baseName, displayName] .map(normalizeText) .some((value) => value.includes(nameKeyword)) + const edgeThickness = parseEdgeBandingThicknessFromSpec(item.spec) + const matchesEdgeThickness = matchesNumberOrTextQuery( + appliedQueryEdgeThickness, + edgeThickness, + [item.spec, displayName], + ) const itemThickness = parseBoardThicknessMmFromText(item.compatibleThicknessMm) const matchesThickness = - queryThicknessValue === null || itemThickness === queryThicknessValue + queryBoardThicknessValue === null || itemThickness === queryBoardThicknessValue + const specWidth = parseEdgeBandingWidthFromSpec(item.spec) + const matchesSpecWidth = matchesNumberOrTextQuery(appliedQuerySpecWidth, specWidth, [ + item.spec, + displayName, + ]) - return matchesName && matchesThickness + return matchesName && matchesEdgeThickness && matchesThickness && matchesSpecWidth }) - }, [appliedQueryName, appliedQueryThickness, normalizedItems]) + }, [ + appliedQueryEdgeThickness, + appliedQueryName, + appliedQuerySpecWidth, + appliedQueryThickness, + normalizedItems, + ]) const sortedFilteredItems = useMemo( () => @@ -255,7 +330,7 @@ export default function EdgeBandingLibraryPanel({ } >() const nameKeyword = normalizeText(appliedQueryName) - const queryThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness) + const queryBoardThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness) categories.forEach((category) => { if ( @@ -266,7 +341,15 @@ export default function EdgeBandingLibraryPanel({ ) { return } - if (queryThicknessValue !== null) return + if ( + !matchesNumberOrTextQuery(appliedQueryEdgeThickness, category.sortThickness, [ + category.edgeThicknessText, + category.label, + ]) + ) { + return + } + if (queryBoardThicknessValue !== null || normalizeText(appliedQuerySpecWidth)) return groupMap.set(category.key, { key: category.key, @@ -308,7 +391,14 @@ export default function EdgeBandingLibraryPanel({ return left.label.localeCompare(right.label, 'zh-CN', { numeric: true }) }) - }, [appliedQueryName, appliedQueryThickness, categories, sortedFilteredItems]) + }, [ + appliedQueryEdgeThickness, + appliedQueryName, + appliedQuerySpecWidth, + appliedQueryThickness, + categories, + sortedFilteredItems, + ]) const [selectedGroupKey, setSelectedGroupKey] = useState(null) const activeGroup = @@ -378,7 +468,9 @@ export default function EdgeBandingLibraryPanel({ function applySearch() { setAppliedQueryName(queryName) + setAppliedQueryEdgeThickness(queryEdgeThickness) setAppliedQueryThickness(queryThickness) + setAppliedQuerySpecWidth(querySpecWidth) } function startCreate() { @@ -583,49 +675,55 @@ export default function EdgeBandingLibraryPanel({ return (
-
-
-

- 封边资料库 -

+
+
+ setQueryName(event.target.value)} + placeholder="例如:PVC / ABS / 同色" + /> + setQueryEdgeThickness(event.target.value)} + placeholder="例如:0.5" + /> + setQueryThickness(event.target.value)} + placeholder="例如:12MM" + /> + setQuerySpecWidth(event.target.value)} + placeholder="例如:18" + />
-
- -
- setQueryName(event.target.value)} - placeholder="例如:PVC封边条 / 22×1" - /> - setQueryThickness(event.target.value)} - placeholder="例如:12MM" - /> -
- +
{canEditItems ? ( ) : null} +
diff --git a/furniture-3d-system/src/app/intake/SteelFrameLibraryPanel.tsx b/furniture-3d-system/src/app/intake/SteelFrameLibraryPanel.tsx index 2ccc6485..18561d57 100644 --- a/furniture-3d-system/src/app/intake/SteelFrameLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/SteelFrameLibraryPanel.tsx @@ -1,11 +1,9 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' -import { Calculator, Plus, Save, Search, SquareStack, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState, type InputHTMLAttributes } from 'react' +import { ChevronDown, Copy, Plus, Save, Search, Trash2 } from 'lucide-react' import Button from '@/components/ui/Button' -import { Input } from '@/components/ui/Input' import { - STEEL_FRAME_TEMPLATE_FIELDS, buildSteelFrameLibraryItems, buildSteelFrameMatchLabel, buildSteelFrameSpecTitle, @@ -24,6 +22,31 @@ function isRoundName(value: string) { return normalizeText(value).includes('圆') } +function formatSteelFrameMetric(value: string, maximumFractionDigits = 2) { + const normalizedValue = value.trim() + const numericValue = Number(normalizedValue.replace(/,/g, '')) + + if (!Number.isFinite(numericValue)) { + return normalizedValue || '-' + } + + return numericValue.toLocaleString('zh-CN', { + maximumFractionDigits, + }) +} + +function formatSteelFrameMetricWithUnit( + value: string, + maximumFractionDigits: number, + unit: string, + prefix = '', +) { + const formattedValue = formatSteelFrameMetric(value, maximumFractionDigits) + if (formattedValue === '-') return formattedValue + + return `${prefix}${formattedValue}${unit}` +} + type SteelFramePanelItem = ReturnType['items'][number] type EditorMode = 'view' | 'create' | 'edit' @@ -32,9 +55,38 @@ const STEEL_FRAME_CREATE_PRESETS = [ { key: 'flat', label: '新增扁管', name: '扁管' }, { key: 'round', label: '新增圆管', name: '圆管' }, ] as const +const STEEL_FRAME_TABLE_GRID = + 'minmax(170px,1.4fr) minmax(56px,0.4fr) minmax(56px,0.4fr) minmax(64px,0.4fr) minmax(96px,0.7fr) minmax(96px,0.7fr) minmax(96px,0.65fr)' +const STEEL_FRAME_FIELD_CLASS = + 'mt-1 h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-xs text-apple-label-primary outline-none placeholder:text-apple-label-tertiary focus:border-apple-blue disabled:cursor-not-allowed disabled:opacity-50' const LIBRARY_PRIMARY_BUTTON_CLASS = '!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400' +function SteelFrameField({ + label, + helperText, + containerClassName = '', + className = '', + ...inputProps +}: InputHTMLAttributes & { + label: string + helperText?: string + containerClassName?: string +}) { + return ( + + ) +} + export default function SteelFrameLibraryPanel({ canEditItems = true, }: { @@ -54,6 +106,7 @@ export default function SteelFrameLibraryPanel({ const [editingItemId, setEditingItemId] = useState(null) const [editorDraft, setEditorDraft] = useState(createEmptySteelFrameDraft()) const [panelMessage, setPanelMessage] = useState('') + const [rulesExpanded, setRulesExpanded] = useState(false) const items = useMemo( () => buildSteelFrameLibraryItems(referenceSteelPrice, savedItems), @@ -159,6 +212,7 @@ export default function SteelFrameLibraryPanel({ ), ) setPanelMessage('') + setRulesExpanded(false) } function handleSelectItem(item: SteelFramePanelItem) { @@ -168,6 +222,7 @@ export default function SteelFrameLibraryPanel({ setEditingItemId(null) setEditorDraft(createEmptySteelFrameDraft()) setPanelMessage('') + setRulesExpanded(false) return } @@ -175,6 +230,7 @@ export default function SteelFrameLibraryPanel({ setEditingItemId(item.id) setEditorDraft(item) setPanelMessage('') + setRulesExpanded(false) } function handleDraftFieldChange( @@ -242,53 +298,68 @@ export default function SteelFrameLibraryPanel({ setPanelMessage('钢架规格已删除。') } + function handleCopyDraft() { + if (!canEditItems) return + if (!editingItemId) return + + setEditorMode('create') + setEditingItemId(null) + setSelectedItemId(null) + setEditorDraft( + hydrateSteelFrameLibraryDraft( + { + name: editorDraft.name, + length: editorDraft.length, + width: editorDraft.width, + wallThickness: editorDraft.wallThickness, + remark: editorDraft.remark, + }, + referenceSteelPrice, + ), + ) + setPanelMessage('已复制为新规格,请修改长、宽或壁厚后保存。') + setRulesExpanded(false) + } + const shouldShowEditor = canEditItems && (editorMode === 'create' || (editorMode === 'edit' && Boolean(editingItemId))) return ( -
-
-
-
-

钢架资料库

-

- 这里不再预置系统钢架规格。你只需要维护每吨钢价,并按需新建方管、扁管、圆管规格;保存后的规格会自动带出重量、支数和每米单价。 -

-
-
- - 已录规格 {items.length} 条 - - - 当前钢价联动重算 - -
-
- -
-
- +
+
+
+ setQueryName(event.target.value)} placeholder="例如:方管 / 扁管 / 圆管" /> - setQuerySize(event.target.value)} placeholder="例如:20x20 / 30x15 / 25x0.7" /> - setQueryWallThickness(event.target.value)} placeholder="例如:0.5" /> + { + if (canEditItems) setReferenceSteelPrice(event.target.value) + }} + placeholder="例如:4200" + readOnly={!canEditItems} + />
-
+
{canEditItems ? STEEL_FRAME_CREATE_PRESETS.map((preset) => (
-
-
-
-
- -

钢管参考价

-
-

- 这里只填“每吨钢管参考价”。你一改这里,下面所有已保存钢架规格的每米单价都会跟着自动刷新。 -

-
- { - if (canEditItems) setReferenceSteelPrice(event.target.value) - }} - placeholder="例如:4200" - readOnly={!canEditItems} - helperText="每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000" - /> +
+
+
+
+

钢架规格列表

+

+ 点一行即可查看或编辑;单价会按当前每吨钢价自动重算。 +

+ + {sortedFilteredItems.length} 条 +
-
-
- -

自动字段

-
-
- {STEEL_FRAME_TEMPLATE_FIELDS.map((field) => ( - - {field} - - ))} -
-
- -
-
-
-

钢架规格列表

-

- 左侧只显示你已经保存的钢架规格。后续谁新建,都会按当前每吨钢价自动重算单价。 -

-
- {sortedFilteredItems.length} 条 +
+
+ 规格 + 长 + 宽 + 壁厚 + 单支重量 + 每吨支数 + 每米单价
-
+
{sortedFilteredItems.length === 0 ? ( -
+
{canEditItems ? '当前查询条件下还没有钢架规格,直接点上面的新增按钮即可开始录入。' : '当前查询条件下还没有钢架规格。'} @@ -385,28 +432,28 @@ export default function SteelFrameLibraryPanel({ key={item.id} type="button" onClick={() => handleSelectItem(item)} - className={`w-full rounded-apple-lg border px-3 py-3 text-left transition ${ + className={`grid w-full gap-3 rounded-apple-lg border px-3 py-3 text-left text-sm transition ${ isActive ? 'border-apple-blue bg-apple-blue/10' - : 'border-apple-separator bg-apple-bg-secondary/40 hover:bg-apple-bg-secondary' + : 'border-apple-separator bg-apple-bg-secondary/30 hover:bg-apple-bg-secondary' }`} + style={{ gridTemplateColumns: STEEL_FRAME_TABLE_GRID, minWidth: 760 }} > -
-
-

- {buildSteelFrameSpecTitle(item)} -

-

- 重量 {item.weight.trim() || '-'} / 支数 {item.pieceCount.trim() || '-'} -

-
-
- - 已保存 - - ¥ {item.unitPrice || '-'} -
-
+ + {buildSteelFrameSpecTitle(item)} + + {item.length || '-'} + {item.width || '-'} + {item.wallThickness || '-'} + + {formatSteelFrameMetricWithUnit(item.weight, 3, ' kg')} + + + {formatSteelFrameMetricWithUnit(item.pieceCount, 1, ' 支')} + + + {formatSteelFrameMetricWithUnit(item.unitPrice, 2, ' /米', '¥ ')} + ) }) @@ -415,60 +462,74 @@ export default function SteelFrameLibraryPanel({
-
+
{shouldShowEditor ? ( -
-
-
-

+
+
+
+

{editorMode === 'create' ? '新建钢架规格' : '编辑钢架规格'}

-

- 这里只需要填名称、长、宽、壁厚,下面会自动带出重量、支数和每米单价。 -

-
+
{editorMode === 'edit' ? ( - + <> + + + ) : null}
-
- + handleDraftFieldChange('name', event.target.value)} placeholder="例如:方管 / 扁管 / 圆管" /> - handleDraftFieldChange('length', event.target.value)} placeholder={draftIsRound ? '例如:25' : '例如:20'} /> - handleDraftFieldChange('width', event.target.value)} placeholder={draftIsRound ? '圆管可留空' : '例如:15'} disabled={draftIsRound} - helperText={draftIsRound ? '圆管按直径 + 壁厚计算,宽度可以不填。' : undefined} /> - handleDraftFieldChange('wallThickness', event.target.value)} @@ -476,25 +537,34 @@ export default function SteelFrameLibraryPanel({ />
-
+
{[ { label: '系统匹配名', value: buildSteelFrameMatchLabel(resolvedEditorDraft) || '-' }, - { label: '单支重量', value: resolvedEditorDraft.weight || '-' }, - { label: '每吨支数', value: resolvedEditorDraft.pieceCount || '-' }, - { label: '当前每米单价', value: resolvedEditorDraft.unitPrice || '-' }, + { + label: '单支重量', + value: formatSteelFrameMetricWithUnit(resolvedEditorDraft.weight, 3, ' kg'), + }, + { + label: '每吨支数', + value: formatSteelFrameMetricWithUnit(resolvedEditorDraft.pieceCount, 1, ' 支'), + }, + { + label: '当前每米单价', + value: formatSteelFrameMetricWithUnit(resolvedEditorDraft.unitPrice, 2, ' /米', '¥ '), + }, ].map((field) => (

{field.label}

-

{field.value}

+

{field.value}

))}
-
- + handleDraftFieldChange('remark', event.target.value)} @@ -503,68 +573,107 @@ export default function SteelFrameLibraryPanel({
{panelMessage ? ( -

{panelMessage}

+

{panelMessage}

) : duplicateItem ? ( -

+

当前规格和左侧“{buildSteelFrameSpecTitle(duplicateItem)}”重复了。

) : null} -
-

计算规则说明

-
-

1. 方管、扁管按矩形管公式自动算重量;圆管按圆管公式自动算重量。

-

2. 每吨支数 = 1000 ÷ 单支重量。

-

3. 每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000。

-

4. 保存后,这条规格会直接参与钢架 BOM 的自动匹配和自动带价。

-
+
+ + {rulesExpanded ? ( +
+
+

1. 方管、扁管按矩形管公式自动算重量;圆管按圆管公式自动算重量。

+

2. 每吨支数 = 1000 ÷ 单支重量。

+

3. 每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000。

+

4. 保存后,这条规格会直接参与钢架 BOM 的自动匹配和自动带价。

+
+
+ ) : null}
) : selectedItem ? ( -
+
-

+

{buildSteelFrameSpecTitle(selectedItem)}

-

+

{canEditItems - ? '这是当前已保存的钢架规格。需要新尺寸时,直接点上方新增按钮即可。' + ? '当前已保存规格。需要新尺寸时,点上方新增按钮。' : '这是当前已保存的钢架规格。当前账号只能查看。'}

-
+
{[ { label: '名称 / 管型', value: selectedItem.name || '-' }, { label: '系统匹配名', value: buildSteelFrameMatchLabel(selectedItem) || '-' }, { label: '长', value: selectedItem.length || '-' }, { label: '宽', value: selectedItem.width || '-' }, { label: '壁厚', value: selectedItem.wallThickness || '-' }, - { label: '单支重量', value: selectedItem.weight || '-' }, - { label: '每吨支数', value: selectedItem.pieceCount || '-' }, - { label: '当前每米单价', value: selectedItem.unitPrice || '-' }, + { + label: '单支重量', + value: formatSteelFrameMetricWithUnit(selectedItem.weight, 3, ' kg'), + }, + { + label: '每吨支数', + value: formatSteelFrameMetricWithUnit(selectedItem.pieceCount, 1, ' 支'), + }, + { + label: '当前每米单价', + value: formatSteelFrameMetricWithUnit(selectedItem.unitPrice, 2, ' /米', '¥ '), + }, ].map((field) => (

{field.label}

-

{field.value}

+

{field.value}

))}
-
-

当前规则说明

-
-

1. 你可以继续用右上角按钮补录新的钢架规格。

-

2. 右侧看到的单价会随每吨钢管参考价变化而自动刷新。

-

3. 钢架 BOM 录入时,只要材质名称写成标准匹配名,系统就会自动带出单价和重量。

-
+
+ + {rulesExpanded ? ( +
+
+

1. 你可以继续用右上角按钮补录新的钢架规格。

+

2. 右侧看到的单价会随每吨钢管参考价变化而自动刷新。

+

3. 钢架 BOM 录入时,只要材质名称写成标准匹配名,系统就会自动带出单价和重量。

+
+
+ ) : null}
) : ( -
+
{canEditItems ? '这里已经没有系统预置规格了。请直接点上方“新增方管 / 新增扁管 / 新增圆管”开始录入。' : '这里还没有钢架规格。当前账号只能查看。'} 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 3526c53b..ef5997a4 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -57,7 +57,8 @@ import { } from "@/lib/non-integrated-quality-inspection-export"; import { buildNonIntegratedBomFileName, - createNonIntegratedBomWorkbook, + createNonIntegratedCuttingBomWorkbook, + createNonIntegratedPanelBomWorkbook, downloadNonIntegratedBomWorkbook, } from "@/lib/non-integrated-bom-export"; import { @@ -9267,8 +9268,15 @@ function BomBoardMaterialField({ options: NonIntegratedBomBoardMaterialOption[]; tone?: "default" | "manual"; }) { + const inputRef = useRef(null); const closeDropdownTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); + const [panelPosition, setPanelPosition] = useState<{ + left: number; + top: number; + width: number; + maxHeight: number; + } | null>(null); const filteredOptions = useMemo( () => filterNonIntegratedBomBoardMaterialOptions(options, value, 20), [options, value], @@ -9287,6 +9295,45 @@ function BomBoardMaterialField({ useEffect(() => clearCloseDropdownTimer, [clearCloseDropdownTimer]); + const updatePanelPosition = useCallback(() => { + if (typeof window === "undefined" || !inputRef.current) return; + + const rect = inputRef.current.getBoundingClientRect(); + const viewportPadding = 12; + const width = Math.min(680, window.innerWidth - viewportPadding * 2); + const left = Math.min( + Math.max(viewportPadding, rect.left), + Math.max(viewportPadding, window.innerWidth - width - viewportPadding), + ); + const top = rect.bottom + 4; + + setPanelPosition({ + left, + top, + width, + maxHeight: Math.max( + 160, + window.innerHeight - rect.bottom - viewportPadding - 4, + ), + }); + }, []); + + useEffect(() => { + if (!panelVisible) { + setPanelPosition(null); + return; + } + + updatePanelPosition(); + window.addEventListener("resize", updatePanelPosition); + window.addEventListener("scroll", updatePanelPosition, true); + + return () => { + window.removeEventListener("resize", updatePanelPosition); + window.removeEventListener("scroll", updatePanelPosition, true); + }; + }, [panelVisible, updatePanelPosition]); + const buildOptionMetaText = useCallback( (option: NonIntegratedBomBoardMaterialOption) => { const metaParts = [ @@ -9317,12 +9364,19 @@ function BomBoardMaterialField({ return (
onChange(event.target.value)} onFocus={() => { clearCloseDropdownTimer(); setIsOpen(true); + updatePanelPosition(); + }} + onClick={() => { + clearCloseDropdownTimer(); + setIsOpen(true); + updatePanelPosition(); }} onBlur={() => { clearCloseDropdownTimer(); @@ -9349,33 +9403,44 @@ function BomBoardMaterialField({ : "bg-apple-bg-secondary" }`} /> - {panelVisible ? ( -
- {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - + )) + ) : ( +
+ 没有找到匹配的板材,请换个名称、编码、颜色或贴面继续搜索。
-
- {buildOptionMetaText(option)} -
- - )) - ) : ( -
- 没有找到匹配的板材,请换个名称、编码、颜色或贴面继续搜索。 -
- )} -
- ) : null} + )} +
, + document.body, + ) + : null}
); } @@ -9547,8 +9612,15 @@ function BomSteelMaterialField({ options: NonIntegratedBomSteelFrameOption[]; tone?: "default" | "manual"; }) { + const inputRef = useRef(null); const closeDropdownTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); + const [panelPosition, setPanelPosition] = useState<{ + left: number; + top: number; + width: number; + maxHeight: number; + } | null>(null); const filteredOptions = useMemo( () => filterNonIntegratedBomSteelFrameOptions(options, value, 20), [options, value], @@ -9567,6 +9639,45 @@ function BomSteelMaterialField({ useEffect(() => clearCloseDropdownTimer, [clearCloseDropdownTimer]); + const updatePanelPosition = useCallback(() => { + if (typeof window === "undefined" || !inputRef.current) return; + + const rect = inputRef.current.getBoundingClientRect(); + const viewportPadding = 12; + const width = Math.min(520, window.innerWidth - viewportPadding * 2); + const left = Math.min( + Math.max(viewportPadding, rect.left), + Math.max(viewportPadding, window.innerWidth - width - viewportPadding), + ); + const top = rect.bottom + 4; + + setPanelPosition({ + left, + top, + width, + maxHeight: Math.max( + 160, + window.innerHeight - rect.bottom - viewportPadding - 4, + ), + }); + }, []); + + useEffect(() => { + if (!panelVisible) { + setPanelPosition(null); + return; + } + + updatePanelPosition(); + window.addEventListener("resize", updatePanelPosition); + window.addEventListener("scroll", updatePanelPosition, true); + + return () => { + window.removeEventListener("resize", updatePanelPosition); + window.removeEventListener("scroll", updatePanelPosition, true); + }; + }, [panelVisible, updatePanelPosition]); + const buildOptionMetaText = useCallback( (option: NonIntegratedBomSteelFrameOption) => { const sizeLabel = [ @@ -9600,12 +9711,19 @@ function BomSteelMaterialField({ return (
onChange(event.target.value)} onFocus={() => { clearCloseDropdownTimer(); setIsOpen(true); + updatePanelPosition(); + }} + onClick={() => { + clearCloseDropdownTimer(); + setIsOpen(true); + updatePanelPosition(); }} onBlur={() => { clearCloseDropdownTimer(); @@ -9632,33 +9750,44 @@ function BomSteelMaterialField({ : "bg-apple-bg-secondary" }`} /> - {panelVisible ? ( -
- {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - + )) + ) : ( +
+ 没有找到匹配的钢架,请换个名称、规格或厚度继续搜索。
-
- {buildOptionMetaText(option)} -
- - )) - ) : ( -
- 没有找到匹配的钢架,请换个名称、规格或厚度继续搜索。 -
- )} -
- ) : null} + )} +
, + document.body, + ) + : null}
); } @@ -10414,7 +10543,9 @@ export default function NonIntegratedDetailClient({ const [isSubmitting, setIsSubmitting] = useState(false); const [isExportingSku, setIsExportingSku] = useState(false); const [isImportingSku, setIsImportingSku] = useState(false); - const [isExportingBom, setIsExportingBom] = useState(false); + const [exportingBomKind, setExportingBomKind] = useState< + "panel" | "cutting" | null + >(null); const [boardMaterialOptions, setBoardMaterialOptions] = useState< NonIntegratedBomBoardMaterialOption[] >([]); @@ -20584,22 +20715,26 @@ export default function NonIntegratedDetailClient({ } } - async function handleExportBomWorkbook() { + async function handleExportBomWorkbook(kind: "panel" | "cutting") { if (!canDownloadWorkspaceFiles) { denyWorkspaceDownloadForCurrentUser(); return; } - if (isExportingBom || selectedBomRows.length === 0) return; + if (exportingBomKind || selectedBomRows.length === 0) return; const fileName = buildNonIntegratedBomFileName({ spu: formValues.spu.trim(), model: selectedSupplementSku?.model?.trim() ?? "", skuCode: selectedSupplementSku?.skuCode?.trim() ?? "", - }); - setIsExportingBom(true); + }, kind); + setExportingBomKind(kind); try { startGeneratedDownloadProgress(fileName); - const workbook = await createNonIntegratedBomWorkbook({ + const createWorkbook = + kind === "panel" + ? createNonIntegratedPanelBomWorkbook + : createNonIntegratedCuttingBomWorkbook; + const workbook = await createWorkbook({ sections: selectedBomRowsByCategory, spu: formValues.spu.trim(), model: selectedSupplementSku?.model?.trim() ?? "", @@ -20619,7 +20754,7 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("error"); setDownloadProgress(null); } finally { - setIsExportingBom(false); + setExportingBomKind(null); } } @@ -28552,7 +28687,7 @@ function renderFactoryQualityWorkspace() { "序号", "图片", "型号", - "规格码", + "货号", "条码", "套件量", "规格名称", @@ -29752,17 +29887,38 @@ function renderFactoryQualityWorkspace() {
{canDownloadWorkspaceFiles ? ( - void handleExportBomWorkbook()} - disabled={ - isExportingBom || - selectedBomRows.length === 0 - } - > - {isExportingBom ? "导出中..." : "导出Excel"} - +
+ + void handleExportBomWorkbook("panel") + } + disabled={ + Boolean(exportingBomKind) || + selectedBomRows.length === 0 + } + > + {exportingBomKind === "panel" + ? "导出中..." + : "导出板式BOM"} + + + void handleExportBomWorkbook("cutting") + } + disabled={ + Boolean(exportingBomKind) || + selectedBomRows.length === 0 + } + > + {exportingBomKind === "cutting" + ? "导出中..." + : "导出开料单BOM"} + +
) : null}
汇总价格: diff --git a/furniture-3d-system/src/lib/non-integrated-bom-export.test.ts b/furniture-3d-system/src/lib/non-integrated-bom-export.test.ts index e105a8c8..5e608405 100644 --- a/furniture-3d-system/src/lib/non-integrated-bom-export.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-bom-export.test.ts @@ -1,7 +1,9 @@ import type { NonIntegratedBomRow } from '@/app/intake/non-integrated/nonIntegratedStorage' import { buildNonIntegratedBomFileName, + createNonIntegratedCuttingBomWorkbook, createNonIntegratedBomWorkbook, + createNonIntegratedPanelBomWorkbook, } from '@/lib/non-integrated-bom-export' import { NON_INTEGRATED_BOM_PRICING_CONFIG } from '@/lib/non-integrated-bom-schema' @@ -66,6 +68,12 @@ function findRowNumber(worksheet: import('exceljs').Worksheet, matcher: string) return matchedRowNumber } +function getFormulaText(value: unknown) { + return value && typeof value === 'object' && 'formula' in value + ? String((value as { formula?: unknown }).formula ?? '') + : '' +} + describe('non-integrated-bom-export', () => { it('exports grouped bom sections with subtotals, edge rows, and total price', async () => { const blob = await createNonIntegratedBomWorkbook({ @@ -118,33 +126,34 @@ describe('non-integrated-bom-export', () => { const worksheet = workbook.getWorksheet('细节资料') expect(worksheet).toBeDefined() - expect(worksheet?.getCell('A1').value).toBe('细节资料清单') - expect(worksheet?.getCell('B2').value).toBe('SZ0230') - expect(worksheet?.getCell('F2').value).toBe('K01') - expect(worksheet?.getCell('J2').value).toBe('SZ023001') - expect(worksheet?.getCell('Q4').value).toBe('¥ 36.04') + expect(worksheet?.getCell('A1').value).toBe('分类') + expect(worksheet?.getCell('B1').value).toBe('编号') + expect(worksheet?.getCell('A2').value).toBe('板材') + expect(worksheet?.getCell('B2').value).toBe('PJ001') + expect(worksheet?.getCell('K2').value).toBe('0.84') + expect(worksheet?.getCell('O2').value).toBe('25.2') - const boardTitleRow = findRowNumber(worksheet!, '板材(2 行)') - expect(boardTitleRow).toBeGreaterThan(0) - expect(worksheet?.getCell(boardTitleRow + 1, 1).value).toBe('编号') - expect(worksheet?.getCell(boardTitleRow + 2, 1).value).toBe('PJ001') - expect(worksheet?.getCell(boardTitleRow + 2, 13).value).toBe('0.84') - expect(worksheet?.getCell(boardTitleRow + 2, 17).value).toBe('25.2') + const edgeRow = findRowNumber(worksheet!, '12E1级刨花板的封边+胶水') + expect(edgeRow).toBeGreaterThan(0) + expect(worksheet?.getCell(edgeRow, 1).value).toBe('封边') + expect(worksheet?.getCell(edgeRow, 10).value).toBe('1.05') + expect(worksheet?.getCell(edgeRow, 12).value).toBe('4') + expect(worksheet?.getCell(edgeRow, 13).value).toBe('0.2') + expect(worksheet?.getCell(edgeRow, 15).value).toBe('0.84') - expect(worksheet?.getCell(boardTitleRow + 3, 2).value).toBe('封边+胶水') - expect(worksheet?.getCell(boardTitleRow + 3, 12).value).toBe('1.05') - expect(worksheet?.getCell(boardTitleRow + 3, 14).value).toBe('4') - expect(worksheet?.getCell(boardTitleRow + 3, 15).value).toBe('0.2') - expect(worksheet?.getCell(boardTitleRow + 3, 17).value).toBe('0.84') - expect(worksheet?.getCell(boardTitleRow + 4, 1).value).toBe('板材小计') - expect(worksheet?.getCell(boardTitleRow + 4, 17).value).toBe('¥ 26.04') + const accessoryRow = findRowNumber(worksheet!, '螺丝包') + expect(accessoryRow).toBeGreaterThan(edgeRow) + expect(worksheet?.getCell(accessoryRow, 1).value).toBe('配件') + expect(worksheet?.getCell(accessoryRow, 2).value).toBe('PJ009') + expect(worksheet?.getCell(accessoryRow, 9).value).toBe('2') + expect(worksheet?.getCell(accessoryRow, 13).value).toBe('5') + expect(worksheet?.getCell(accessoryRow, 14).value).toBe('0.2') + expect(worksheet?.getCell(accessoryRow, 15).value).toBe('10') + expect(worksheet?.getCell(accessoryRow, 16).value).toBe('配件备注') - const accessoryTitleRow = findRowNumber(worksheet!, '配件(1 行)') - expect(accessoryTitleRow).toBeGreaterThan(boardTitleRow) - expect(worksheet?.getCell(accessoryTitleRow + 2, 1).value).toBe('PJ009') - expect(worksheet?.getCell(accessoryTitleRow + 2, 13).value).toBe('2') - expect(worksheet?.getCell(accessoryTitleRow + 2, 17).value).toBe('10') - expect(worksheet?.getCell(accessoryTitleRow + 2, 18).value).toBe('配件备注') + const totalRow = findRowNumber(worksheet!, '总材料费') + expect(totalRow).toBeGreaterThan(accessoryRow) + expect(worksheet?.getCell(totalRow, 15).value).toBe('36.04') }) it('exports manual synthetic unit-price overrides', async () => { @@ -285,13 +294,13 @@ describe('non-integrated-bom-export', () => { const workbook = await loadWorkbookFromBlob(blob) const worksheet = workbook.worksheets[0] - const accessoryTitleRow = findRowNumber(worksheet!, '配件(1 行)') + const accessoryRow = findRowNumber(worksheet!, '测试配件') - expect(accessoryTitleRow).toBeGreaterThan(0) - expect(worksheet?.getCell(accessoryTitleRow + 2, 10).value).toBe('') - expect(worksheet?.getCell(accessoryTitleRow + 2, 15).value).toBe('8') - expect(worksheet?.getCell(accessoryTitleRow + 2, 16).value).toBe('') - expect(worksheet?.getCell(accessoryTitleRow + 2, 17).value).toBe('24') + expect(accessoryRow).toBeGreaterThan(0) + expect(worksheet?.getCell(accessoryRow, 8).value ?? '').toBe('') + expect(worksheet?.getCell(accessoryRow, 13).value).toBe('8') + expect(worksheet?.getCell(accessoryRow, 14).value ?? '').toBe('') + expect(worksheet?.getCell(accessoryRow, 15).value).toBe('24') } finally { const configIndex = NON_INTEGRATED_BOM_PRICING_CONFIG.findIndex( (item) => item.id === 'accessory-test-config', @@ -302,6 +311,85 @@ describe('non-integrated-bom-export', () => { } }) + it('exports panel bom with steel length in column E', async () => { + const blob = await createNonIntegratedPanelBomWorkbook({ + spu: 'CJ0034', + model: 'K01', + skuCode: 'CJ003401', + sections: [ + { + category: '钢架', + rows: [ + createBomRow({ + category: '钢架', + partCode: 'G1', + partName: '15*15*0.7方管', + material: '15*15*0.7方管', + length: '1440', + width: '999', + unit: '根', + quantity: '2', + factor: '1.01', + unitPrice: '2.3', + }), + ], + }, + ], + }) + + const workbook = await loadWorkbookFromBlob(blob) + const worksheet = workbook.worksheets[0] + const steelRow = findRowNumber(worksheet!, '15*15*0.7方管') + + expect(steelRow).toBeGreaterThan(0) + expect(worksheet?.getCell(steelRow, 1).value).toBe('钢材') + expect(worksheet?.getCell(steelRow, 5).value).toBe(1440) + expect(worksheet?.getCell(steelRow, 6).value).toBeNull() + expect(getFormulaText(worksheet?.getCell(steelRow, 12).value)).toBe( + `IF(OR(E${steelRow}="",J${steelRow}=""),"",E${steelRow}*J${steelRow}/1000)`, + ) + expect(getFormulaText(worksheet?.getCell(steelRow, 15).value)).toBe( + `IF(OR(K${steelRow}="",L${steelRow}="",N${steelRow}=""),"",L${steelRow}*N${steelRow}*K${steelRow})`, + ) + }) + + it('exports cutting bom workbook using the cutting template sections', async () => { + const blob = await createNonIntegratedCuttingBomWorkbook({ + spu: 'SM001', + model: 'K01', + sections: [ + { + category: '板材', + rows: [ + createBomRow({ + category: '板材', + partCode: 'B1', + partName: '桌面', + material: '橡胶木', + length: '1200', + width: '600', + quantity: '1', + specSize: '18', + }), + ], + }, + ], + }) + + const workbook = await loadWorkbookFromBlob(blob) + const worksheet = workbook.worksheets[0] + const boardRow = findRowNumber(worksheet!, '桌面') + + expect(worksheet?.getCell('A1').value).toBe('产品材料明细表') + expect(boardRow).toBeGreaterThan(0) + expect(worksheet?.getCell(boardRow, 5).value).toBe(1200) + expect(worksheet?.getCell(boardRow, 6).value).toBe(600) + expect(worksheet?.getCell(boardRow, 7).value).toBe(18) + expect(getFormulaText(worksheet?.getCell(boardRow, 10).value)).toBe( + `IF(OR(E${boardRow}="",F${boardRow}="",G${boardRow}="",H${boardRow}=""),"",E${boardRow}*F${boardRow}*G${boardRow}*H${boardRow}/1000000000)`, + ) + }) + it('builds a readable file name from current spu and model', () => { expect( buildNonIntegratedBomFileName({ @@ -310,5 +398,15 @@ describe('non-integrated-bom-export', () => { skuCode: 'SZ023001', }), ).toBe('SZ0230-K01-细节资料.xlsx') + expect( + buildNonIntegratedBomFileName( + { + spu: 'SZ0230', + model: 'K01', + skuCode: 'SZ023001', + }, + 'panel', + ), + ).toBe('SZ0230-K01-板式BOM.xlsx') }) }) diff --git a/furniture-3d-system/src/lib/non-integrated-bom-export.ts b/furniture-3d-system/src/lib/non-integrated-bom-export.ts index e9dd7a46..a8b4641a 100644 --- a/furniture-3d-system/src/lib/non-integrated-bom-export.ts +++ b/furniture-3d-system/src/lib/non-integrated-bom-export.ts @@ -49,6 +49,8 @@ type NonIntegratedBomExportBlock = { treatAsLabor?: boolean } +type NonIntegratedBomWorkbookKind = 'detail' | 'panel' | 'cutting' + type NonIntegratedBomWorkbookInput = { sections: NonIntegratedBomExportSection[] spu?: string @@ -173,6 +175,11 @@ function normalizeFileNamePart(value = '') { return value.trim().replace(/[\\/:*?"<>|]/g, '-') } +function normalizeSheetName(value = '') { + const normalized = normalizeFileNamePart(value).replace(/[\[\]]/g, '-').trim() + return (normalized || 'BOM').slice(0, 31) +} + function getBomExportBaseName({ spu = '', model = '', @@ -263,6 +270,446 @@ function getBomRowsSubtotal(rows: NonIntegratedBomDisplayRow[]) { return rows.reduce((sum, row) => sum + (getNonIntegratedBomDisplayRowTotalPrice(row) ?? 0), 0) } +function parseBomExportNumber(value?: string | number | null) { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value !== 'string') return null + + const normalized = value.trim().replace(/,/g, '') + if (!normalized) return null + + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null +} + +function getBomText(value?: string | number | null) { + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '' + return value?.trim() ?? '' +} + +function getBomNumberOrText(value?: string | number | null) { + const parsed = parseBomExportNumber(value) + if (parsed !== null) return parsed + return getBomText(value) +} + +function getBomDisplayName(row: NonIntegratedBomDisplayRow) { + if ( + isNonIntegratedBomSyntheticRow(row) && + (row.syntheticKind === 'edge-banding' || + row.syntheticKind === 'wood-paint' || + row.syntheticKind === 'spray') + ) { + return row.material || row.partName + } + + return row.partName || row.material || row.structure +} + +function getBomRemark(row: NonIntegratedBomDisplayRow) { + return [row.remark?.trim(), row.process?.trim()].filter(Boolean).join(';') +} + +function setCellValue( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + columnNumber: number, + value: string | number | null | { formula: string }, +) { + const cell = worksheet.getCell(rowNumber, columnNumber) + cell.value = value === '' ? null : value + return cell +} + +function styleBomTemplateRange( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + fromColumn: number, + toColumn: number, + options: { + bold?: boolean + align?: 'left' | 'center' | 'right' + fillColor?: string + } = {}, +) { + for (let column = fromColumn; column <= toColumn; column += 1) { + const cell = worksheet.getCell(rowNumber, column) + applyCellStyle(cell, { + bold: options.bold, + align: options.align ?? (column >= 10 && column <= 15 ? 'right' : 'center'), + fillColor: options.fillColor, + }) + cell.alignment = { + ...cell.alignment, + wrapText: false, + } + } +} + +function setBomTemplateNumberFormat( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + columns: number[], + numFmt = '0.###', +) { + columns.forEach((column) => { + worksheet.getCell(rowNumber, column).numFmt = numFmt + }) +} + +function setPanelBomBaseRowValues( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + values: Partial>, +) { + for (let column = 1; column <= 16; column += 1) { + setCellValue(worksheet, rowNumber, column, values[column] ?? '') + } + styleBomTemplateRange(worksheet, rowNumber, 1, 16) + setBomTemplateNumberFormat(worksheet, rowNumber, [5, 6, 7, 8, 10, 11, 12, 13, 14, 15]) +} + +function appendPanelBomSubtotalRow( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + subtotalStartRow: number, + subtotalEndRow: number, +) { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: '小计', + 15: + subtotalEndRow >= subtotalStartRow + ? { formula: `SUM(O${subtotalStartRow}:O${subtotalEndRow})` } + : 0, + }) + styleBomTemplateRange(worksheet, rowNumber, 1, 16, { + bold: true, + fillColor: 'FFF8FAFC', + }) +} + +function appendPanelBomDisplayRow( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + row: NonIntegratedBomDisplayRow, + mode: + | 'board' + | 'edge' + | 'paint' + | 'steel' + | 'spray' + | 'accessory' + | 'foam' + | 'foam-corner' + | 'carton' + | 'package' + | 'labor', + categoryLabel: string, + options: { steelStartRow?: number; steelEndRow?: number } = {}, +) { + const factor = getBomNumberOrText(row.factor || '1') + const unitPrice = getBomNumberOrText(row.unitPrice) + const quantity = getBomNumberOrText(row.quantity) + const pricingQuantity = getNonIntegratedBomDisplayRowPricingQuantity(row) + const edgeMeters = getNonIntegratedBomDisplayRowEdgeMeters(row) + + if (mode === 'board') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 4: getBomText(row.structure), + 5: getBomNumberOrText(row.length), + 6: getBomNumberOrText(row.width), + 7: getBomNumberOrText(row.longEdgeCount), + 8: getBomNumberOrText(row.shortEdgeCount), + 9: getBomText(row.unit), + 10: quantity, + 11: factor, + 12: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}="",J${rowNumber}=""),"",E${rowNumber}*F${rowNumber}*J${rowNumber}/1000000)` }, + 13: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}="",J${rowNumber}=""),"",(E${rowNumber}*G${rowNumber}+F${rowNumber}*H${rowNumber})*J${rowNumber}/1000)` }, + 14: unitPrice, + 15: { formula: `IF(OR(K${rowNumber}="",L${rowNumber}="",N${rowNumber}=""),"",K${rowNumber}*L${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'edge') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 9: getBomText(row.unit), + 11: factor, + 13: edgeMeters ?? '', + 14: unitPrice, + 15: { formula: `IF(OR(K${rowNumber}="",M${rowNumber}="",N${rowNumber}=""),"",K${rowNumber}*M${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'paint') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 9: getBomText(row.unit), + 11: factor, + 12: pricingQuantity ?? '', + 14: unitPrice, + 15: { formula: `IF(OR(K${rowNumber}="",L${rowNumber}="",N${rowNumber}=""),"",K${rowNumber}*L${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'steel') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 4: getBomText(row.structure), + 5: getBomNumberOrText(row.length), + 9: getBomText(row.unit), + 10: quantity, + 11: factor, + 12: { formula: `IF(OR(E${rowNumber}="",J${rowNumber}=""),"",E${rowNumber}*J${rowNumber}/1000)` }, + 14: unitPrice, + 15: { formula: `IF(OR(K${rowNumber}="",L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber}*K${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'spray') { + const steelRange = + options.steelStartRow && options.steelEndRow && options.steelEndRow >= options.steelStartRow + ? `SUM(L${options.steelStartRow}:L${options.steelEndRow})` + : String(pricingQuantity ?? 0) + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 12: { formula: steelRange }, + 13: { formula: `L${rowNumber}*0.06` }, + 14: unitPrice, + 15: { formula: `IF(OR(M${rowNumber}="",N${rowNumber}=""),"",M${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'accessory' || mode === 'labor') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 4: getBomText(row.structure), + 9: getBomText(row.unit), + 10: quantity, + 14: unitPrice, + 15: { formula: `IF(N${rowNumber}="","",IF(J${rowNumber}="",N${rowNumber},J${rowNumber}*N${rowNumber}))` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'foam') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 5: getBomNumberOrText(row.length), + 6: getBomNumberOrText(row.width), + 7: getBomNumberOrText(row.longEdgeCount), + 8: quantity, + 9: getBomText(row.unit), + 12: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}="",G${rowNumber}="",H${rowNumber}=""),"",E${rowNumber}*F${rowNumber}*G${rowNumber}*H${rowNumber}/1000000000)` }, + 14: unitPrice, + 15: { formula: `IF(OR(L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + if (mode === 'carton') { + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 5: getBomNumberOrText(row.length), + 6: getBomNumberOrText(row.width), + 9: getBomText(row.unit), + 10: quantity, + 12: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}=""),"",E${rowNumber}*F${rowNumber}/1000000)` }, + 14: unitPrice, + 15: { formula: `IF(OR(L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) + return + } + + setPanelBomBaseRowValues(worksheet, rowNumber, { + 1: categoryLabel, + 2: getBomText(row.partCode), + 3: getBomDisplayName(row), + 5: getBomNumberOrText(row.length), + 9: getBomText(row.unit), + 10: quantity, + 12: pricingQuantity ?? '', + 14: unitPrice, + 15: { formula: `IF(OR(L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber})` }, + 16: getBomRemark(row), + }) +} + +function appendPanelBomBlock( + worksheet: import('exceljs').Worksheet, + startRow: number, + rows: NonIntegratedBomDisplayRow[], + label: string, + mode: Parameters[3], + options: { treatAsLabor?: boolean; steelStartRow?: number; steelEndRow?: number } = {}, +) { + if (rows.length === 0) { + return { + nextRow: startRow, + subtotalRow: 0, + dataStartRow: 0, + dataEndRow: 0, + treatAsLabor: Boolean(options.treatAsLabor), + } + } + + let rowCursor = startRow + const dataStartRow = rowCursor + rows.forEach((row) => { + appendPanelBomDisplayRow(worksheet, rowCursor, row, mode, label, options) + rowCursor += 1 + }) + const dataEndRow = rowCursor - 1 + appendPanelBomSubtotalRow(worksheet, rowCursor, dataStartRow, dataEndRow) + + return { + nextRow: rowCursor + 1, + subtotalRow: rowCursor, + dataStartRow, + dataEndRow, + treatAsLabor: Boolean(options.treatAsLabor), + } +} + +function applyPanelBomWorksheetLayout(worksheet: import('exceljs').Worksheet) { + const widths = [10, 12, 28, 8, 11, 11, 9, 9, 10, 10, 10, 12, 12, 10, 12, 22] + worksheet.columns = widths.map((width) => ({ width })) + worksheet.properties.defaultRowHeight = 22 + worksheet.views = [{ state: 'frozen', ySplit: 2 }] +} + +function getPackageExportMode(subcategory: NonIntegratedBomPackageSubcategory) { + if (subcategory === '泡沫') return 'foam' + if (subcategory === '泡沫护角') return 'foam-corner' + if (subcategory === '纸箱') return 'carton' + if (subcategory === '人工杂费') return 'labor' + return 'package' +} + +function setCuttingCell( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + columnNumber: number, + value: string | number | null | { formula: string }, +) { + const cell = setCellValue(worksheet, rowNumber, columnNumber, value) + applyCellStyle(cell, { + align: columnNumber >= 5 && columnNumber <= 10 ? 'right' : 'center', + }) + cell.alignment = { + ...cell.alignment, + wrapText: false, + } + return cell +} + +function styleCuttingSectionTitle( + worksheet: import('exceljs').Worksheet, + rowNumber: number, + title: string, +) { + worksheet.mergeCells(rowNumber, 1, rowNumber, 11) + const cell = worksheet.getCell(rowNumber, 1) + cell.value = title + applyCellStyle(cell, { + bold: true, + align: 'center', + fillColor: 'FFF3F5F7', + }) +} + +function inferBoardThickness(row: NonIntegratedBomDisplayRow) { + const fromSpec = parseBomExportNumber(row.specSize) + if (fromSpec !== null) return fromSpec + + const sourceText = [row.material, row.partName, row.remark].filter(Boolean).join(' ') + const matched = sourceText.match(/(\d+(?:\.\d+)?)\s*(?:mm|毫米|厘|厚|密度板|刨花板|生态板|板)/i) + if (!matched) return '' + + const parsed = Number(matched[1]) + return Number.isFinite(parsed) ? parsed : '' +} + +function appendCuttingBoardHeader(worksheet: import('exceljs').Worksheet, rowNumber: number) { + worksheet.mergeCells(rowNumber, 1, rowNumber + 1, 1) + worksheet.mergeCells(rowNumber, 2, rowNumber + 1, 2) + worksheet.mergeCells(rowNumber, 3, rowNumber + 1, 3) + worksheet.mergeCells(rowNumber, 4, rowNumber + 1, 4) + worksheet.mergeCells(rowNumber, 5, rowNumber, 7) + worksheet.mergeCells(rowNumber, 8, rowNumber + 1, 8) + worksheet.mergeCells(rowNumber, 9, rowNumber + 1, 9) + worksheet.mergeCells(rowNumber, 10, rowNumber + 1, 10) + worksheet.mergeCells(rowNumber, 11, rowNumber + 1, 11) + + const labels = [ + [1, '序号'], + [2, '零部件序号'], + [3, '零部件名称'], + [4, '材质'], + [5, '成品尺寸'], + [8, '成品数量'], + [9, '板厚等级'], + [10, '材积m³'], + [11, '备注'], + ] as const + + labels.forEach(([column, label]) => { + const cell = worksheet.getCell(rowNumber, column) + cell.value = label + }) + worksheet.getCell(rowNumber + 1, 5).value = '长' + worksheet.getCell(rowNumber + 1, 6).value = '宽' + worksheet.getCell(rowNumber + 1, 7).value = '厚' + + for (let row = rowNumber; row <= rowNumber + 1; row += 1) { + styleBomTemplateRange(worksheet, row, 1, 11, { + bold: true, + fillColor: 'FFF8FAFC', + }) + } +} + +function appendCuttingAccessoryHeader(worksheet: import('exceljs').Worksheet, rowNumber: number) { + const headers = ['序号', '五金名称', '规格', '材质', '颜色', '数量', '单位', '供应商', '单价', '金额', '备注'] + headers.forEach((header, index) => { + const cell = worksheet.getCell(rowNumber, index + 1) + cell.value = header + }) + styleBomTemplateRange(worksheet, rowNumber, 1, 11, { + bold: true, + fillColor: 'FFF8FAFC', + }) +} + function createExportBlock( label: string, rows: NonIntegratedBomDisplayRow[], @@ -547,10 +994,332 @@ export async function createNonIntegratedBomWorkbook({ return new Blob([buffer], { type: EXCEL_MIME_TYPE }) } +export async function createNonIntegratedPanelBomWorkbook({ + sections, + spu = '', + model = '', + skuCode = '', + edgeBandingOptions = [], +}: NonIntegratedBomWorkbookInput) { + const ExcelJSImport = await import('exceljs') + const ExcelJS = ExcelJSImport as typeof import('exceljs') + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.lastModifiedBy = 'Codex' + workbook.created = new Date() + workbook.modified = new Date() + + const worksheet = workbook.addWorksheet( + normalizeSheetName(getBomExportBaseName({ spu, model, skuCode })), + ) + applyPanelBomWorksheetLayout(worksheet) + worksheet.mergeCells('A1:P1') + const titleCell = worksheet.getCell('A1') + titleCell.value = `型号:${[spu, model || skuCode].filter(Boolean).join(' ')}` + applyCellStyle(titleCell, { + bold: true, + align: 'center', + fillColor: 'FFEAF2FF', + }) + titleCell.font = { name: 'Microsoft YaHei', size: 12, bold: true } + + const headers = [ + '分类', + '编号', + '名称', + '1', + '长', + '宽', + '长边', + '短边', + '单位', + '数量', + '系数', + '面积', + '封边米数', + '单价', + '金额', + '备注', + ] + headers.forEach((header, index) => { + const cell = worksheet.getCell(2, index + 1) + cell.value = header + }) + styleBomTemplateRange(worksheet, 2, 1, 16, { + bold: true, + fillColor: 'FFF3F5F7', + }) + + const resolvedSections = getResolvedSections(sections, edgeBandingOptions) + const subtotalRows: { subtotalRow: number; treatAsLabor: boolean }[] = [] + let rowCursor = 3 + let steelDataStartRow = 0 + let steelDataEndRow = 0 + + resolvedSections.forEach((section) => { + if (section.category === '板材') { + const regularRows = section.displayRows.filter((row) => !isNonIntegratedBomSyntheticRow(row)) + const edgeRows = section.displayRows.filter( + (row) => isNonIntegratedBomSyntheticRow(row) && row.syntheticKind === 'edge-banding', + ) + const paintRows = section.displayRows.filter( + (row) => isNonIntegratedBomSyntheticRow(row) && row.syntheticKind === 'wood-paint', + ) + + const boardBlock = appendPanelBomBlock(worksheet, rowCursor, regularRows, '板材', 'board') + rowCursor = boardBlock.nextRow + if (boardBlock.subtotalRow) subtotalRows.push(boardBlock) + + const edgeBlock = appendPanelBomBlock(worksheet, rowCursor, edgeRows, '封边', 'edge') + rowCursor = edgeBlock.nextRow + if (edgeBlock.subtotalRow) subtotalRows.push(edgeBlock) + + const paintBlock = appendPanelBomBlock(worksheet, rowCursor, paintRows, '油漆', 'paint') + rowCursor = paintBlock.nextRow + if (paintBlock.subtotalRow) subtotalRows.push(paintBlock) + return + } + + if (section.category === '钢架') { + const regularRows = section.displayRows.filter((row) => !isNonIntegratedBomSyntheticRow(row)) + const sprayRows = section.displayRows.filter( + (row) => isNonIntegratedBomSyntheticRow(row) && row.syntheticKind === 'spray', + ) + + const steelBlock = appendPanelBomBlock(worksheet, rowCursor, regularRows, '钢材', 'steel') + rowCursor = steelBlock.nextRow + steelDataStartRow = steelBlock.dataStartRow + steelDataEndRow = steelBlock.dataEndRow + if (steelBlock.subtotalRow) subtotalRows.push(steelBlock) + + const sprayBlock = appendPanelBomBlock(worksheet, rowCursor, sprayRows, '其他', 'spray', { + steelStartRow: steelDataStartRow, + steelEndRow: steelDataEndRow, + }) + rowCursor = sprayBlock.nextRow + if (sprayBlock.subtotalRow) subtotalRows.push(sprayBlock) + return + } + + if (section.category === '配件') { + const accessoryBlock = appendPanelBomBlock( + worksheet, + rowCursor, + section.displayRows, + '配件', + 'accessory', + ) + rowCursor = accessoryBlock.nextRow + if (accessoryBlock.subtotalRow) subtotalRows.push(accessoryBlock) + return + } + + if (section.category === '包材') { + section.packageGroups.forEach((group) => { + const packageBlock = appendPanelBomBlock( + worksheet, + rowCursor, + group.displayRows, + group.subcategory, + getPackageExportMode(group.subcategory), + { + treatAsLabor: group.subcategory === '人工杂费', + }, + ) + rowCursor = packageBlock.nextRow + if (packageBlock.subtotalRow) subtotalRows.push(packageBlock) + }) + } + }) + + const materialSubtotalRows = subtotalRows + .filter((item) => !item.treatAsLabor) + .map((item) => `O${item.subtotalRow}`) + const laborSubtotalRows = subtotalRows + .filter((item) => item.treatAsLabor) + .map((item) => `O${item.subtotalRow}`) + const materialFormula = materialSubtotalRows.length ? materialSubtotalRows.join('+') : '0' + const laborFormula = laborSubtotalRows.length ? laborSubtotalRows.join('+') : '0' + + setPanelBomBaseRowValues(worksheet, rowCursor, { + 1: '总材料费', + 15: { formula: materialFormula }, + }) + styleBomTemplateRange(worksheet, rowCursor, 1, 16, { + bold: true, + fillColor: 'FFFFF8EC', + }) + const materialTotalRow = rowCursor + rowCursor += 1 + + setPanelBomBaseRowValues(worksheet, rowCursor, { + 1: '人工+杂费', + 15: { formula: laborFormula }, + }) + styleBomTemplateRange(worksheet, rowCursor, 1, 16, { + bold: true, + fillColor: 'FFFFF8EC', + }) + const laborTotalRow = rowCursor + rowCursor += 1 + + setPanelBomBaseRowValues(worksheet, rowCursor, { + 1: '总成本', + 15: { formula: `SUM(O${materialTotalRow}:O${laborTotalRow})` }, + }) + styleBomTemplateRange(worksheet, rowCursor, 1, 16, { + bold: true, + fillColor: 'FFFFF3D9', + }) + + const buffer = await workbook.xlsx.writeBuffer() + return new Blob([buffer], { type: EXCEL_MIME_TYPE }) +} + +export async function createNonIntegratedCuttingBomWorkbook({ + sections, + spu = '', + model = '', + skuCode = '', + edgeBandingOptions = [], +}: NonIntegratedBomWorkbookInput) { + const ExcelJSImport = await import('exceljs') + const ExcelJS = ExcelJSImport as typeof import('exceljs') + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.lastModifiedBy = 'Codex' + workbook.created = new Date() + workbook.modified = new Date() + + const worksheet = workbook.addWorksheet('Sheet1') + worksheet.columns = [8, 14, 20, 20, 10, 10, 10, 10, 12, 12, 22].map((width) => ({ + width, + })) + worksheet.properties.defaultRowHeight = 22 + worksheet.views = [{ state: 'frozen', ySplit: 5 }] + + worksheet.mergeCells('A1:K1') + const titleCell = worksheet.getCell('A1') + titleCell.value = '产品材料明细表' + applyCellStyle(titleCell, { + bold: true, + align: 'center', + fillColor: 'FFEAF2FF', + }) + titleCell.font = { name: 'Microsoft YaHei', size: 14, bold: true } + + const infoLabels = ['产品编号', spu || skuCode, '产品名称', model, '产品规格', '', '表面涂装', '', '制表日期', new Date()] + infoLabels.forEach((value, index) => { + const cell = worksheet.getCell(2, index + 1) + cell.value = value + applyCellStyle(cell, { + bold: index % 2 === 0, + align: index % 2 === 0 ? 'center' : 'left', + fillColor: index % 2 === 0 ? 'FFF8FAFC' : undefined, + }) + }) + worksheet.getCell(2, 10).numFmt = 'yyyy-mm-dd' + + const resolvedSections = getResolvedSections(sections, edgeBandingOptions) + const boardRows = + resolvedSections + .find((section) => section.category === '板材') + ?.displayRows.filter( + (row) => + !isNonIntegratedBomSyntheticRow(row) || + row.syntheticKind === 'wood-paint', + ) ?? [] + const accessoryRows = + resolvedSections.find((section) => section.category === '配件')?.displayRows ?? [] + const steelRows = + resolvedSections.find((section) => section.category === '钢架')?.displayRows ?? [] + const packageRows = + resolvedSections.find((section) => section.category === '包材')?.displayRows ?? [] + + let rowCursor = 3 + styleCuttingSectionTitle(worksheet, rowCursor, '实木明细表') + rowCursor += 1 + appendCuttingBoardHeader(worksheet, rowCursor) + rowCursor += 2 + const boardStartRow = rowCursor + boardRows.forEach((row, index) => { + setCuttingCell(worksheet, rowCursor, 1, index + 1) + setCuttingCell(worksheet, rowCursor, 2, getBomText(row.partCode)) + setCuttingCell(worksheet, rowCursor, 3, getBomDisplayName(row)) + setCuttingCell(worksheet, rowCursor, 4, row.material || row.structure) + setCuttingCell(worksheet, rowCursor, 5, getBomNumberOrText(row.length)) + setCuttingCell(worksheet, rowCursor, 6, getBomNumberOrText(row.width)) + setCuttingCell(worksheet, rowCursor, 7, inferBoardThickness(row)) + setCuttingCell(worksheet, rowCursor, 8, getBomNumberOrText(row.quantity)) + setCuttingCell(worksheet, rowCursor, 9, getBomText(row.specSize)) + setCuttingCell(worksheet, rowCursor, 10, { + formula: `IF(OR(E${rowCursor}="",F${rowCursor}="",G${rowCursor}="",H${rowCursor}=""),"",E${rowCursor}*F${rowCursor}*G${rowCursor}*H${rowCursor}/1000000000)`, + }) + setCuttingCell(worksheet, rowCursor, 11, getBomRemark(row)) + rowCursor += 1 + }) + const boardEndRow = rowCursor - 1 + worksheet.mergeCells(rowCursor, 1, rowCursor, 9) + setCuttingCell(worksheet, rowCursor, 1, '材积合计') + setCuttingCell(worksheet, rowCursor, 10, { + formula: boardEndRow >= boardStartRow ? `SUM(J${boardStartRow}:J${boardEndRow})` : '0', + }) + setCuttingCell(worksheet, rowCursor, 11, '') + styleBomTemplateRange(worksheet, rowCursor, 1, 11, { + bold: true, + fillColor: 'FFFFF8EC', + }) + rowCursor += 1 + + const appendCuttingSimpleSection = (title: string, rows: NonIntegratedBomDisplayRow[]) => { + rowCursor += 1 + styleCuttingSectionTitle(worksheet, rowCursor, title) + rowCursor += 1 + appendCuttingAccessoryHeader(worksheet, rowCursor) + rowCursor += 1 + rows.forEach((row, index) => { + setCuttingCell(worksheet, rowCursor, 1, index + 1) + setCuttingCell(worksheet, rowCursor, 2, getBomDisplayName(row)) + setCuttingCell(worksheet, rowCursor, 3, row.specSize || row.length || row.width || '') + setCuttingCell(worksheet, rowCursor, 4, row.material || row.structure || '') + setCuttingCell(worksheet, rowCursor, 5, '') + setCuttingCell(worksheet, rowCursor, 6, getBomNumberOrText(row.quantity)) + setCuttingCell(worksheet, rowCursor, 7, getBomText(row.unit)) + setCuttingCell(worksheet, rowCursor, 8, '') + setCuttingCell(worksheet, rowCursor, 9, getBomNumberOrText(row.unitPrice)) + setCuttingCell(worksheet, rowCursor, 10, { + formula: `IF(OR(F${rowCursor}="",I${rowCursor}=""),"",F${rowCursor}*I${rowCursor})`, + }) + setCuttingCell(worksheet, rowCursor, 11, getBomRemark(row)) + rowCursor += 1 + }) + } + + appendCuttingSimpleSection('组装五金明细表', [...accessoryRows, ...steelRows]) + appendCuttingSimpleSection('组包五金明细表', packageRows) + + rowCursor += 1 + worksheet.mergeCells(rowCursor, 1, rowCursor, 11) + const makerCell = worksheet.getCell(rowCursor, 1) + makerCell.value = '制表人:' + applyCellStyle(makerCell, { + align: 'left', + }) + + const buffer = await workbook.xlsx.writeBuffer() + return new Blob([buffer], { type: EXCEL_MIME_TYPE }) +} + export function buildNonIntegratedBomFileName( options: Pick, + kind: NonIntegratedBomWorkbookKind = 'detail', ) { - return `${getBomExportBaseName(options)}-细节资料.xlsx` + const suffixByKind: Record = { + detail: '细节资料', + panel: '板式BOM', + cutting: '开料单BOM', + } + return `${getBomExportBaseName(options)}-${suffixByKind[kind]}.xlsx` } export function downloadNonIntegratedBomWorkbook(blob: Blob, fileName: string) { From 180680f348fc9b585ab639115c242e0ab97af174 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 28 May 2026 08:54:42 +0800 Subject: [PATCH 5/8] feat(intake): add sku hole snapping preview --- .../src/app/intake/IntakeClient.tsx | 114 ++++++ .../NonIntegratedDetailClient.test.tsx | 114 +++++- .../NonIntegratedDetailClient.tsx | 332 +++++++++++++++++- .../non-integrated/nonIntegratedStorage.ts | 75 ++++ furniture-3d-system/src/app/intake/page.tsx | 115 ++++++ .../services/approval-workflow.service.ts | 4 +- 6 files changed, 735 insertions(+), 19 deletions(-) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index dd439bf7..bef5f684 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -73,6 +73,7 @@ import { type NonIntegratedDocumentWorkspaceCategory, type NonIntegratedProductRecord, type NonIntegratedSkuComponentDetail, + type NonIntegratedSkuHolePoint, type NonIntegratedWorkflowNodeCategory, } from "./non-integrated/nonIntegratedStorage"; import NonIntegratedDetailClient from "./non-integrated/NonIntegratedDetailClient"; @@ -900,6 +901,7 @@ type WorkbenchSku = { status: string; componentCodes: string[]; componentDetails?: NonIntegratedSkuComponentDetail[]; + holePoints?: NonIntegratedSkuHolePoint[]; skuFields?: SeriesDraftSkuFields; }; @@ -2075,6 +2077,117 @@ function readPreSkuTextField( return ""; } +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readFiniteNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readVector3Tuple(value: unknown): [number, number, number] | null { + if (!Array.isArray(value) || value.length < 3) return null; + + const x = readFiniteNumber(value[0]); + const y = readFiniteNumber(value[1]); + const z = readFiniteNumber(value[2]); + if (x === null || y === null || z === null) return null; + + return [x, y, z]; +} + +function readRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is Record => + isPlainRecord(item), + ) + : []; +} + +function readTrimmedString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function buildSkuHolePointFromRecord(params: { + record: Record; + fallbackId: string; + panelId: string; + panelName: string; + sourceNodeName: string; +}): NonIntegratedSkuHolePoint | null { + const position = readVector3Tuple(params.record.position); + if (!position) return null; + + return { + id: readTrimmedString(params.record.id) || params.fallbackId, + panelId: readTrimmedString(params.record.panelId) || params.panelId, + panelName: readTrimmedString(params.record.panelName) || params.panelName, + position, + normal: readVector3Tuple(params.record.normal) ?? [0, 1, 0], + diameter: + readFiniteNumber(params.record.diameter) ?? + readFiniteNumber(params.record.width) ?? + 5, + depth: readFiniteNumber(params.record.depth) ?? 10, + holeType: + readTrimmedString(params.record.holeType) || + readTrimmedString(params.record.type) || + "mount-point", + sourceNodeName: params.sourceNodeName, + }; +} + +function buildSkuHolePointsFromVariantSelection( + preSku: DraftGroupDetailDTO["preSkus"][number], +): NonIntegratedSkuHolePoint[] { + const selection = preSku.variantSelection; + const entries = [ + ...(selection.slots ?? []), + ...(selection.hardwareSelections ?? []), + ]; + + return entries.flatMap((entry, entryIndex) => { + const sourceNodeName = entry.code || entry.name || `node-${entryIndex + 1}`; + const panelId = entry.variantId || sourceNodeName; + const panelName = entry.name || sourceNodeName; + const assemblyBasis = isPlainRecord(entry.assemblyBasis) + ? entry.assemblyBasis + : null; + const holes = readRecordArray(assemblyBasis?.holes) + .map((record, holeIndex) => + buildSkuHolePointFromRecord({ + record, + fallbackId: `${panelId}:hole-${holeIndex + 1}`, + panelId, + panelName, + sourceNodeName, + }), + ) + .filter((item): item is NonIntegratedSkuHolePoint => item !== null); + + if (holes.length > 0) return holes; + + return readRecordArray(entry.mountPoints) + .map((record, mountIndex) => + buildSkuHolePointFromRecord({ + record: { + ...record, + id: + readTrimmedString(record.id) || + readTrimmedString(record.name) || + `mount-${mountIndex + 1}`, + holeType: readTrimmedString(record.type) || "mount-point", + }, + fallbackId: `${panelId}:mount-${mountIndex + 1}`, + panelId, + panelName, + sourceNodeName, + }), + ) + .filter((item): item is NonIntegratedSkuHolePoint => item !== null); + }); +} + function mapStatus(status: string) { if (status === "FORMALIZED") return "已完成"; if (status === "READY_FOR_INTAKE") return "待执行"; @@ -2209,6 +2322,7 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[] status: mapStatus(preSku.status), componentCodes: preSku.componentCodes ?? [], componentDetails: readPreSkuComponentDetails(preSku), + holePoints: buildSkuHolePointsFromVariantSelection(preSku), skuFields: readPreSkuSkuFields(preSku), })); } 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 e1067a11..826df12b 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 @@ -96,6 +96,11 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({ nodeName: string; material: { id?: string; textureRepeatU?: number }; }>; + holes?: Array; + activeHolePanelIds?: string[]; + panelNodeNameByPanelId?: Record; + panelLabelByPanelId?: Record; + showHoleHighlights?: boolean; selectedNodeName?: string | null; selectedNodeNames?: string[]; enableTranslateGizmo?: boolean; @@ -4770,7 +4775,7 @@ describe("NonIntegratedDetailClient", () => { const workspace = screen.getByTestId("sample-measurement-workspace"); expect(workspace).toBeInTheDocument(); expect(panel).toHaveTextContent("型号"); - expect(panel).toHaveTextContent("规格码"); + expect(panel).toHaveTextContent("货号"); expect(panel).toHaveTextContent("套件量"); expect(panel).toHaveTextContent("供应商名称"); expect(panel).toHaveTextContent("条码规格名称"); @@ -5550,7 +5555,7 @@ describe("NonIntegratedDetailClient", () => { const pricingWorkspace = screen.getByTestId("product-pricing-workspace"); expect(pricingWorkspace).toBeInTheDocument(); - expect(pricingWorkspace).toHaveTextContent("规格码"); + expect(pricingWorkspace).toHaveTextContent("货号"); expect(pricingWorkspace).toHaveTextContent("条码"); expect(pricingWorkspace).toHaveTextContent("成本价"); expect(pricingWorkspace).toHaveTextContent("是否录入系统"); @@ -7324,6 +7329,111 @@ describe("NonIntegratedDetailClient", () => { ); }); + it("passes SKU hole points into 3D preview and snaps moved nodes to nearby holes", async () => { + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-3d-hole-snap", + skuItems: [ + createSkuItem({ + id: "sku-3d-hole-snap", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + holePoints: [ + { + id: "leg-origin", + panelId: "leg", + panelName: "桌腿", + position: [0, 0, 0], + normal: [0, 1, 0], + diameter: 6, + depth: 10, + holeType: "mount-point", + sourceNodeName: "leg-node", + }, + { + id: "desktop-hole", + panelId: "desktop", + panelName: "桌面", + position: [100, 0, 0], + normal: [0, 1, 0], + diameter: 6, + depth: 10, + holeType: "mount-point", + sourceNodeName: "desktop-node", + }, + ], + }), + ], + }), + ]), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + + await waitFor(() => + expect(getProductPreviewProps().modelUrl).toBeTruthy(), + ); + + expect(getProductPreviewProps().holes).toHaveLength(2); + + act(() => { + ( + getProductPreviewProps().onGizmoInteractionStart as ( + nodeName: string, + ) => void + )("leg-node"); + ( + getProductPreviewProps().onNodePositionOffsetChange as (payload: { + nodeName: string; + offset: [number, number, number]; + }) => void + )({ nodeName: "leg-node", offset: [96, 0, 0] }); + }); + + await waitFor(() => + expect(getProductPreviewProps().nodePositionOffsets).toEqual([ + { nodeName: "leg-node", offset: [100, 0, 0] }, + ]), + ); + + expect( + (getProductPreviewProps().holes as Array<{ id: string; isHighlighted?: boolean }>) + .find((hole) => hole.id === "desktop-hole")?.isHighlighted, + ).toBe(true); + + act(() => { + ( + getProductPreviewProps().onGizmoInteractionEnd as ( + nodeName: string, + ) => void + )("leg-node"); + }); + + await waitFor(() => + expect(getProductPreviewProps().nodePositionOffsets).toEqual([ + { nodeName: "leg-node", offset: [100, 0, 0] }, + ]), + ); + }); + it("applies SKU material to the whole model and supports node material overrides", async () => { const actor = userEvent.setup(); const walnutFinish = createMaterialFinish({ 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 ef5997a4..04159677 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -34,6 +34,7 @@ import { type ContextMenuItem, } from "@/components/ui/ContextMenu"; import type { NodePositionOffset, NodeRotationState } from "@/lib/3d/engine"; +import type { HoleData as HoleOverlayData } from "@/components/3d/HoleViewer"; import type { MaterialConfig } from "@/lib/3d/material-manager"; import { toMaterialConfig } from "@/lib/material-config"; import { @@ -197,6 +198,7 @@ import { normalizeNonIntegratedPatentApplication, normalizeNonIntegratedProjectReview, normalizeNonIntegratedProjectStatus, + normalizeNonIntegratedSkuHolePoints, replaceNonIntegratedProducts, saveNonIntegratedProduct, stripNonIntegratedBrandPrefix, @@ -226,6 +228,7 @@ import { type NonIntegratedReviewSheet, type NonIntegratedSampleMeasurementRow, type NonIntegratedSkuComponentDetail, + type NonIntegratedSkuHolePoint, type NonIntegratedSkuItem, type NonIntegratedStyleDetail, type NonIntegratedUploadFile, @@ -633,6 +636,7 @@ type SeriesDraftSkuOption = { status: string; componentCodes: string[]; componentDetails?: NonIntegratedSkuComponentDetail[]; + holePoints?: NonIntegratedSkuHolePoint[]; skuFields?: SeriesDraftSkuFields; }; @@ -727,6 +731,10 @@ type PreviewNodeTransformState = { offsets: PreviewNodeOffsetMap; rotations: PreviewNodeRotationMap; }; +type PreviewHoleSnapTarget = NonIntegratedSkuHolePoint & { + worldPosition: [number, number, number]; + snapOffset: [number, number, number]; +}; type PreviewUvEditorFormState = { textureRepeat: string; textureRepeatU: string; @@ -819,6 +827,7 @@ function buildSkuContentRevisionSignature({ salesStatus: item.salesStatus, tagMaterial: item.tagMaterial, componentDetails: item.componentDetails ?? [], + holePoints: item.holePoints ?? [], source: item.source ?? "", sourceTable: item.sourceTable ?? "", sourceSkuId: item.sourceSkuId ?? "", @@ -3078,6 +3087,7 @@ function createEmptySkuItem(): NonIntegratedSkuItem { model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, model3dNodeMaterialIds: {}, model3dUvConfig: null, + holePoints: [], }; } @@ -3212,6 +3222,167 @@ function inferComponentSkuMaterial(value: string) { return matchedMaterials.length > 0 ? matchedMaterials.join(" / ") : ""; } +function buildSeriesDraftSkuHolePoints( + option: SeriesDraftSkuOption, +): NonIntegratedSkuHolePoint[] { + return normalizeNonIntegratedSkuHolePoints(option.holePoints); +} + +function addTuple3( + left: [number, number, number], + right: [number, number, number], +): [number, number, number] { + return [left[0] + right[0], left[1] + right[1], left[2] + right[2]]; +} + +function subtractTuple3( + left: [number, number, number], + right: [number, number, number], +): [number, number, number] { + return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]; +} + +function getTuple3Distance( + left: [number, number, number], + right: [number, number, number], +) { + return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); +} + +function resolvePreviewHoleSnapThreshold(holePoints: NonIntegratedSkuHolePoint[]) { + if (holePoints.length === 0) return 0; + + const maxDiameter = Math.max(...holePoints.map((point) => point.diameter || 0)); + return Math.max(18, Math.min(80, maxDiameter * 4 || 24)); +} + +function buildPreviewHoleOverlayState(params: { + holePoints?: NonIntegratedSkuHolePoint[]; + nodeOffsetMap: PreviewNodeOffsetMap; + activeNodeName?: string | null; + snapTarget?: PreviewHoleSnapTarget | null; +}) { + const normalizedHolePoints = normalizeNonIntegratedSkuHolePoints( + params.holePoints, + ); + const panelNodeNameByPanelId: Record = {}; + const panelLabelByPanelId: Record = {}; + const activeHolePanelIds: string[] = []; + + const holes = normalizedHolePoints.map((point) => { + const nodeName = point.sourceNodeName?.trim() || ""; + const offset = nodeName ? params.nodeOffsetMap[nodeName] : undefined; + const worldPosition = offset ? addTuple3(point.position, offset) : point.position; + const isSnapTarget = params.snapTarget?.id === point.id; + const panelId = point.panelId || nodeName || "model-root"; + + if (nodeName) { + panelNodeNameByPanelId[panelId] = nodeName; + activeHolePanelIds.push(panelId); + } + panelLabelByPanelId[panelId] = point.panelName || nodeName || panelId; + + return { + id: point.id, + panelId, + position: worldPosition, + coordinateSpace: "model-root", + diameter: point.diameter || 5, + depth: point.depth || 10, + normal: point.normal, + holeType: isSnapTarget ? "assembly-active" : point.holeType || "mount-point", + confidence: 1, + isHighlighted: isSnapTarget, + }; + }); + + return { + holes, + activeHolePanelIds: + activeHolePanelIds.length > 0 + ? Array.from(new Set(activeHolePanelIds)) + : normalizedHolePoints.map((point) => point.panelId).filter(Boolean), + panelNodeNameByPanelId, + panelLabelByPanelId, + }; +} + +function findPreviewHoleSnapTarget(params: { + nodeName: string; + nextOffset: [number, number, number]; + nodeOffsetMap: PreviewNodeOffsetMap; + holePoints?: NonIntegratedSkuHolePoint[]; +}) { + const allPoints = normalizeNonIntegratedSkuHolePoints(params.holePoints); + const sourcePoints = allPoints.filter( + (point) => point.sourceNodeName === params.nodeName, + ); + const targetPoints = allPoints.filter( + (point) => point.sourceNodeName !== params.nodeName, + ); + if (targetPoints.length === 0) return null; + + const effectiveSourcePoints = + sourcePoints.length > 0 + ? sourcePoints + : [ + { + id: `${params.nodeName}:origin`, + panelId: params.nodeName, + panelName: params.nodeName, + position: [0, 0, 0] as [number, number, number], + normal: [0, 1, 0] as [number, number, number], + diameter: 5, + depth: 10, + holeType: "mount-point", + sourceNodeName: params.nodeName, + }, + ]; + const threshold = resolvePreviewHoleSnapThreshold([ + ...effectiveSourcePoints, + ...targetPoints, + ]); + let best: + | { + point: NonIntegratedSkuHolePoint; + worldPosition: [number, number, number]; + snapOffset: [number, number, number]; + distance: number; + } + | null = null; + + for (const targetPoint of targetPoints) { + const targetOffset: [number, number, number] = targetPoint.sourceNodeName + ? params.nodeOffsetMap[targetPoint.sourceNodeName] ?? [0, 0, 0] + : [0, 0, 0]; + const targetWorldPosition = addTuple3(targetPoint.position, targetOffset); + + for (const sourcePoint of effectiveSourcePoints) { + const sourceWorldPosition = addTuple3( + sourcePoint.position, + params.nextOffset, + ); + const distance = getTuple3Distance(sourceWorldPosition, targetWorldPosition); + if (!best || distance < best.distance) { + best = { + point: targetPoint, + worldPosition: targetWorldPosition, + snapOffset: subtractTuple3(targetWorldPosition, sourcePoint.position), + distance, + }; + } + } + } + + if (!best || best.distance > threshold) return null; + + return { + ...best.point, + worldPosition: best.worldPosition, + snapOffset: best.snapOffset, + }; +} + function normalizeSeriesDraftSkuSequence( option: SeriesDraftSkuOption, fallbackIndex: number, @@ -3239,6 +3410,7 @@ function createSkuItemFromSeriesDraftSku( const normalizedName = option.name.trim(); const skuFields = option.skuFields; const componentDetails = resolveSeriesDraftSkuComponentDetails(option); + const holePoints = buildSeriesDraftSkuHolePoints(option); const size = normalizedName.match(/\d+\s*\*\s*\d+/)?.[0]?.replace(/\s+/g, "") ?? ""; const color = normalizedName.includes("胡桃色") ? "胡桃色" : ""; const material = [ @@ -3267,6 +3439,8 @@ function createSkuItemFromSeriesDraftSku( drawingId: skuFields?.drawingId.trim().toUpperCase() || "", installMethod: skuFields?.installMethod.trim() || "", componentDetails, + holePoints, + sourceSkuId: option.id, }; } @@ -11090,6 +11264,7 @@ export default function NonIntegratedDetailClient({ const [previewGizmoMode, setPreviewGizmoMode] = useState< "translate" | "rotate" >("translate"); + const previewTransformModeRef = useRef<"translate" | "rotate">("translate"); const [previewNodeOffsetMap, setPreviewNodeOffsetMap] = useState({}); const [previewNodeRotationMap, setPreviewNodeRotationMap] = @@ -11104,6 +11279,11 @@ export default function NonIntegratedDetailClient({ const previewTransformBaselineRef = useRef(null); const previewTransformNodeNameRef = useRef(null); + const previewActiveHoleSnapTargetRef = useRef( + null, + ); + const [previewActiveHoleSnapTarget, setPreviewActiveHoleSnapTarget] = + useState(null); const [previewContextMenuState, setPreviewContextMenuState] = useState<{ visible: boolean; x: number; @@ -12250,6 +12430,10 @@ export default function NonIntegratedDetailClient({ selectedStyleModelUvConfig, [selectedStyleModelUvConfig, selectedSupplementSourceSku?.model3dUvConfig], ); + const effectivePreviewHolePoints = useMemo( + () => normalizeNonIntegratedSkuHolePoints(selectedSupplementSourceSku?.holePoints), + [selectedSupplementSourceSku?.holePoints], + ); const previewNodeOffsets = useMemo( () => Object.entries(previewNodeOffsetMap).map(([nodeName, offset]) => ({ @@ -12266,6 +12450,21 @@ export default function NonIntegratedDetailClient({ })), [previewNodeRotationMap], ); + const previewHoleOverlayState = useMemo( + () => + buildPreviewHoleOverlayState({ + holePoints: effectivePreviewHolePoints, + nodeOffsetMap: previewNodeOffsetMap, + activeNodeName: selectedPreviewNodeName, + snapTarget: previewActiveHoleSnapTarget, + }), + [ + effectivePreviewHolePoints, + previewActiveHoleSnapTarget, + previewNodeOffsetMap, + selectedPreviewNodeName, + ], + ); const activeSelectedPreviewNodeNames = useMemo(() => { if (selectedPreviewNodeNames.length > 0) { return selectedPreviewNodeNames; @@ -12400,6 +12599,12 @@ export default function NonIntegratedDetailClient({ useEffect(() => { previewNodeRotationMapRef.current = previewNodeRotationMap; }, [previewNodeRotationMap]); + useEffect(() => { + previewTransformModeRef.current = previewGizmoMode; + }, [previewGizmoMode]); + useEffect(() => { + previewActiveHoleSnapTargetRef.current = previewActiveHoleSnapTarget; + }, [previewActiveHoleSnapTarget]); useEffect(() => { previewUvEditorFormRef.current = previewUvEditorForm; }, [previewUvEditorForm]); @@ -15204,6 +15409,8 @@ export default function NonIntegratedDetailClient({ setPreviewNodeTransformRedoStack([]); previewTransformBaselineRef.current = null; previewTransformNodeNameRef.current = null; + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); return; } @@ -15226,6 +15433,8 @@ export default function NonIntegratedDetailClient({ setPreviewNodeTransformRedoStack([]); previewTransformBaselineRef.current = null; previewTransformNodeNameRef.current = null; + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); return () => { if (previewUrl.startsWith("blob:")) { @@ -15261,6 +15470,8 @@ export default function NonIntegratedDetailClient({ setPreviewNodeTransformRedoStack([]); previewTransformBaselineRef.current = null; previewTransformNodeNameRef.current = null; + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); return; } @@ -15284,6 +15495,8 @@ export default function NonIntegratedDetailClient({ setPreviewNodeTransformRedoStack([]); previewTransformBaselineRef.current = null; previewTransformNodeNameRef.current = null; + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); return () => { if (previewUrl.startsWith("blob:")) { @@ -15363,14 +15576,33 @@ export default function NonIntegratedDetailClient({ return; } + previewTransformModeRef.current = previewGizmoMode; + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); previewTransformBaselineRef.current = { offsets: clonePreviewNodeOffsetMap(previewNodeOffsetMapRef.current), rotations: clonePreviewNodeRotationMap(previewNodeRotationMapRef.current), }; previewTransformNodeNameRef.current = nodeName; - }, []); + }, [previewGizmoMode]); const handlePreviewTransformEnd = useCallback((nodeName: string) => { + const snapTarget = previewActiveHoleSnapTargetRef.current; + if ( + snapTarget && + previewTransformNodeNameRef.current === nodeName && + previewTransformModeRef.current === "translate" + ) { + const nextOffsets = { + ...previewNodeOffsetMapRef.current, + [nodeName]: snapTarget.snapOffset, + }; + previewNodeOffsetMapRef.current = nextOffsets; + setPreviewNodeOffsetMap(nextOffsets); + } + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); + const baseline = previewTransformBaselineRef.current; const activeNodeName = previewTransformNodeNameRef.current; previewTransformBaselineRef.current = null; @@ -15394,6 +15626,8 @@ export default function NonIntegratedDetailClient({ clonePreviewNodeTransformState(baseline), ]); setPreviewNodeTransformRedoStack([]); + previewActiveHoleSnapTargetRef.current = null; + setPreviewActiveHoleSnapTarget(null); }, [applyPreviewNodeTransformState]); const handlePreviewUndo = useCallback(() => { @@ -15610,18 +15844,44 @@ export default function NonIntegratedDetailClient({ ...currentOffsets, [payload.nodeName]: payload.offset, }; - previewNodeOffsetMapRef.current = nextOffsets; + const snapTarget = + previewTransformModeRef.current === "translate" + ? findPreviewHoleSnapTarget({ + nodeName: payload.nodeName, + nextOffset: payload.offset, + nodeOffsetMap: nextOffsets, + holePoints: effectivePreviewHolePoints, + }) + : null; + previewActiveHoleSnapTargetRef.current = snapTarget; + setPreviewActiveHoleSnapTarget((current) => + current?.id === snapTarget?.id && + current?.snapOffset[0] === snapTarget?.snapOffset[0] && + current?.snapOffset[1] === snapTarget?.snapOffset[1] && + current?.snapOffset[2] === snapTarget?.snapOffset[2] + ? current + : snapTarget, + ); + + const effectiveOffsets = snapTarget + ? { + ...nextOffsets, + [payload.nodeName]: snapTarget.snapOffset, + } + : nextOffsets; + previewNodeOffsetMapRef.current = effectiveOffsets; if (previewTransformBaselineRef.current) { + setPreviewNodeOffsetMap(effectiveOffsets); return; } applyPreviewNodeTransformState({ - offsets: nextOffsets, + offsets: effectiveOffsets, rotations: previewNodeRotationMapRef.current, }); }, - [applyPreviewNodeTransformState], + [applyPreviewNodeTransformState, effectivePreviewHolePoints], ); const handlePreviewNodeRotationChange = useCallback( @@ -18037,7 +18297,7 @@ export default function NonIntegratedDetailClient({ setSaveMessage( unmatchedCount > 0 - ? `已匹配 ${matchedCount} 张,${unmatchedCount} 张图片名称未匹配到规格码` + ? `已匹配 ${matchedCount} 张,${unmatchedCount} 张图片名称未匹配到货号` : `已匹配 ${matchedCount} 张SKU图片`, ); setSaveMessageType(unmatchedCount > 0 ? "error" : "success"); @@ -22300,6 +22560,27 @@ export default function NonIntegratedDetailClient({ nodePositionOffsets={previewNodeOffsets} nodeRotationStates={previewNodeRotations} materialAssignments={packagingPreviewMaterialAssignments} + holes={ + isPackagingModelPreviewActive + ? [] + : previewHoleOverlayState.holes + } + activeHolePanelIds={ + isPackagingModelPreviewActive + ? [] + : previewHoleOverlayState.activeHolePanelIds + } + panelNodeNameByPanelId={ + isPackagingModelPreviewActive + ? {} + : previewHoleOverlayState.panelNodeNameByPanelId + } + panelLabelByPanelId={ + isPackagingModelPreviewActive + ? {} + : previewHoleOverlayState.panelLabelByPanelId + } + showHoleHighlights={!isPackagingModelPreviewActive} onNodePositionOffsetChange={ isReadOnly ? undefined : handlePreviewNodePositionOffsetChange } @@ -22767,7 +23048,7 @@ export default function NonIntegratedDetailClient({ "序号", "图片", "型号", - "规格码", + "货号", "套件量", "条码", "条码规格名称", @@ -22820,7 +23101,7 @@ export default function NonIntegratedDetailClient({ {renderFactoryInput(row, "model", "请输入型号")}

- {renderFactoryInput(row, "skuCode", "请输入规格码")} + {renderFactoryInput(row, "skuCode", "请输入货号")} {renderFactoryInput(row, "setCount", "请输入套件量", { @@ -23007,7 +23288,7 @@ export default function NonIntegratedDetailClient({ {[ "定厂", "型号", - "规格码", + "货号", "套件量", "规格名称", "尺寸", @@ -23116,7 +23397,7 @@ export default function NonIntegratedDetailClient({