diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/contracts/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/contracts/route.ts new file mode 100644 index 00000000..f5dcca7b --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/contracts/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AppError, ErrorCode } from "@/server/core/errors"; +import { handleRequest, jsonError } from "@/server/core/response"; +import { intakeNonIntegratedProductService } from "@/server/services/intake-non-integrated-product.service"; +import { + createIntakeProductFactoryContractDocx, + normalizeProductFactoryContractBatch, + normalizeProductFactoryContractType, +} from "@/server/services/intake-contract-docx.service"; +import { isIntakeNonIntegratedSupplyChainUser } from "@/lib/non-integrated-permissions"; +import type { AuthUser } from "@/server/core/types"; + +export const runtime = "nodejs"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function canDownloadProductFactoryContract(user: AuthUser) { + if (user.role === "ADMIN" || user.role === "SUPER_ADMIN") return true; + + return isIntakeNonIntegratedSupplyChainUser({ + id: user.id, + name: user.name, + role: user.role, + businessRoles: user.businessRoles, + department: user.department, + departmentPaths: user.departmentPaths, + }); +} + +function buildContentDisposition(fileName: string) { + const fallbackName = fileName.replace(/[^\x20-\x7E]+/g, "_"); + return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent( + fileName, + )}`; +} + +export async function GET(request: NextRequest, context: RouteContext) { + return handleRequest( + request, + async (ctx) => { + if (!canDownloadProductFactoryContract(ctx.user)) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + "当前账号没有下载产品定厂合同的权限", + 403, + ctx.requestId, + ); + } + + const { id } = await context.params; + const productId = decodeURIComponent(id); + const searchParams = request.nextUrl.searchParams; + const contractType = normalizeProductFactoryContractType( + searchParams.get("type"), + ); + const batch = normalizeProductFactoryContractBatch( + searchParams.get("batch"), + ); + + if (!contractType) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "合同类型不正确", + 400, + ctx.requestId, + ); + } + + try { + const product = await intakeNonIntegratedProductService.getById( + productId, + ctx.user, + ); + if (!product) { + return jsonError( + ErrorCode.PRODUCT_NOT_FOUND, + "非集成产品不存在", + 404, + ctx.requestId, + ); + } + + const contract = await createIntakeProductFactoryContractDocx({ + product, + contractType, + batch, + }); + + const contractArrayBuffer = new ArrayBuffer(contract.bytes.byteLength); + new Uint8Array(contractArrayBuffer).set(contract.bytes); + const contractBlob = new Blob([contractArrayBuffer], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + + return new NextResponse(contractBlob, { + status: 200, + headers: { + "Content-Type": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Content-Disposition": buildContentDisposition(contract.fileName), + "Cache-Control": "no-store", + "X-Request-Id": ctx.requestId, + }, + }); + } catch (error) { + if (error instanceof AppError) { + return jsonError( + error.code, + error.message, + error.statusCode, + ctx.requestId, + ); + } + throw error; + } + }, + { requireAuth: true }, + ); +} diff --git a/furniture-3d-system/src/app/globals.css b/furniture-3d-system/src/app/globals.css index 2cea3d15..0e8078ec 100644 --- a/furniture-3d-system/src/app/globals.css +++ b/furniture-3d-system/src/app/globals.css @@ -1161,6 +1161,34 @@ background: rgba(45, 51, 56, 0.25); } + .non-integrated-table-scroll { + overflow-x: scroll !important; + overflow-y: hidden; + padding-bottom: 12px; + scrollbar-color: rgba(45, 51, 56, 0.34) rgba(45, 51, 56, 0.08); + scrollbar-width: auto; + } + + .non-integrated-table-scroll::-webkit-scrollbar { + display: block; + height: 12px; + } + + .non-integrated-table-scroll::-webkit-scrollbar-track { + background: rgba(45, 51, 56, 0.1); + border-radius: 9999px; + } + + .non-integrated-table-scroll::-webkit-scrollbar-thumb { + background: rgba(45, 51, 56, 0.32); + border: 3px solid rgba(45, 51, 56, 0.1); + border-radius: 9999px; + } + + .non-integrated-table-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(45, 51, 56, 0.48); + } + [data-theme='dark'] ::-webkit-scrollbar-thumb { background: rgba(220, 227, 233, 0.15); } @@ -1168,6 +1196,25 @@ [data-theme='dark'] ::-webkit-scrollbar-thumb:hover { background: rgba(220, 227, 233, 0.25); } + + [data-theme='dark'] .non-integrated-table-scroll { + scrollbar-color: rgba(220, 227, 233, 0.36) + rgba(220, 227, 233, 0.1); + } + + [data-theme='dark'] .non-integrated-table-scroll::-webkit-scrollbar-track { + background: rgba(220, 227, 233, 0.1); + } + + [data-theme='dark'] .non-integrated-table-scroll::-webkit-scrollbar-thumb { + background: rgba(220, 227, 233, 0.36); + border: 3px solid rgba(220, 227, 233, 0.1); + } + + [data-theme='dark'] + .non-integrated-table-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(220, 227, 233, 0.5); + } } /* 3D 相关样式 */ diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index db4deca5..34483777 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -9573,6 +9573,7 @@ export default function IntakeClient(props: IntakeClientProps) { seriesNameOptions={seriesNameOptions} existingSeriesNames={existingSeriesNames} seriesDraftSkuOptions={seriesDraftSkuOptions} + materialLibraryFinishes={props.materialLibraryFinishes} initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view} initialDocumentWorkspaceCategory={activeWorkspaceTab.detailState?.category} currentUserRole={props.user.role} @@ -9649,6 +9650,7 @@ export default function IntakeClient(props: IntakeClientProps) { seriesNameOptions={seriesNameOptions} existingSeriesNames={existingSeriesNames} seriesDraftSkuOptions={seriesDraftSkuOptions} + materialLibraryFinishes={props.materialLibraryFinishes} currentUserRole={props.user.role} currentUserId={props.user.id} currentUserName={props.user.name} 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 750fff7c..e1e44eba 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 @@ -35,6 +35,7 @@ import { PRODUCT_PATENT_STORAGE_KEY, type ProductPatentRecord, } from "../productPatentStorage"; +import type { MaterialDTO } from "@/server/services"; import { SPU_RULE_LIBRARY_STORAGE_KEY } from "../spuRuleLibraryLogic"; import { parseZipFile } from "@/lib/zip-parser"; import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip"; @@ -62,8 +63,15 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({ emptyTitle?: string; emptyDescription?: string; overlayTopRight?: ReactNode; + overlayRight?: ReactNode; overlayBottomCenter?: ReactNode; onModelNodesChange?: (nodeNames: string[]) => void; + onNodeContextMenu?: (payload: { + nodeName?: string | null; + rawNodeName?: string | null; + clientX: number; + clientY: number; + }) => void; onNodePositionOffsetChange?: (payload: { nodeName: string; offset: [number, number, number]; @@ -83,7 +91,14 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({ nodeName: string; rotation: [number, number, number, number]; }>; + materialAssignments?: Array<{ + nodeName: string; + material: { id?: string; textureRepeatU?: number }; + }>; selectedNodeName?: string | null; + selectedNodeNames?: string[]; + enableTranslateGizmo?: boolean; + gizmoSize?: number; }) => { modelPreviewPanelMock(props); return ( @@ -93,6 +108,7 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({
{props.emptyTitle}
{props.emptyDescription}
{props.overlayTopRight}
+
{props.overlayRight}
{props.overlayBottomCenter}
); @@ -316,6 +332,8 @@ function createProductRecord( productImageName: "", size: "", material: "", + barcode: "", + reviewResult: "", color: "", issues: [], }, @@ -371,10 +389,46 @@ function createSkuItem( model3dDataUrl: "", model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dNodeMaterialIds: {}, ...overrides, }; } +function createMaterialFinish( + overrides: Partial & Pick, +): MaterialDTO { + return { + id: overrides.id, + name: overrides.name, + code: overrides.code ?? overrides.id.toUpperCase(), + categoryId: overrides.categoryId ?? "material-category-test", + categoryName: overrides.categoryName ?? "wood", + categoryIcon: overrides.categoryIcon ?? null, + thumbnailUrl: overrides.thumbnailUrl ?? "/materials/thumbnails/default.svg", + previewUrl: overrides.previewUrl, + textureUrl: overrides.textureUrl ?? null, + normalMapUrl: overrides.normalMapUrl ?? null, + roughnessMapUrl: overrides.roughnessMapUrl ?? null, + metalnessMapUrl: overrides.metalnessMapUrl ?? null, + aoMapUrl: overrides.aoMapUrl ?? null, + color: overrides.color ?? "#8b5a2b", + metalness: overrides.metalness ?? 0, + roughness: overrides.roughness ?? 0.65, + opacity: overrides.opacity ?? 1, + transmission: overrides.transmission, + ior: overrides.ior, + thickness: overrides.thickness, + clearcoat: overrides.clearcoat, + clearcoatRoughness: overrides.clearcoatRoughness, + envMapIntensity: overrides.envMapIntensity, + textureRepeat: overrides.textureRepeat ?? 1, + textureRotation: overrides.textureRotation ?? 0, + textureSizeMm: overrides.textureSizeMm ?? 1000, + isPreset: overrides.isPreset ?? false, + isActive: overrides.isActive ?? true, + }; +} + describe("NonIntegratedDetailClient", () => { let anchorClickSpy: jest.SpyInstance; @@ -508,7 +562,8 @@ describe("NonIntegratedDetailClient", () => { ) as NonIntegratedProductRecord[]; expect(savedProducts).toHaveLength(1); - expect(savedProducts[0]?.spu).toBe("KK202605001"); + expect(savedProducts[0]?.spu).toBe("GBGJ-KK202605001"); + expect(savedProducts[0]?.productCode).toBe("GBGJ-KK202605001"); expect(savedProducts[0]?.projectStatus).toBe("draft"); expect(savedProducts[0]?.createdBy).toBe("Creator"); expect(savedProducts[0]?.updatedBy).toBe("Creator"); @@ -579,6 +634,7 @@ describe("NonIntegratedDetailClient", () => { await actor.type(screen.getByLabelText("外观设计师"), "外观A"); await selectInlineOption(actor, screen.getByLabelText("品牌"), "代理品牌-顾家"); await actor.type(screen.getByLabelText("商品企划"), "企划A"); + expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:GBGJ-SZ0241"); const imageFile = new File(["image"], "series.png", { type: "image/png" }); const fileInputs = container.querySelectorAll('input[type="file"]'); @@ -592,7 +648,7 @@ describe("NonIntegratedDetailClient", () => { ) as NonIntegratedProductRecord[]; expect(savedProducts[0]?.productDevelopmentKind).toBe("series"); - expect(savedProducts[0]?.spu).toBe("SZ0241"); + expect(savedProducts[0]?.spu).toBe("GBGJ-SZ0241"); expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列"); expect(savedProducts[0]?.salesCategory).toBe("桌类"); expect(savedProducts[0]?.productCategory).toBe("书桌"); @@ -671,10 +727,11 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("SPU")).toBeInTheDocument(); expect(screen.getByLabelText("系列名称")).toBeInTheDocument(); + await selectInlineOption(actor, screen.getByLabelText("品牌"), "代理品牌-顾家"); await actor.type(screen.getByLabelText("SPU"), "CJ0250"); + expect(screen.getByLabelText("SPU")).toHaveValue("GBGJ-CJ0250"); await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌系列"); await actor.type(screen.getByLabelText("外观设计师"), "外观A"); - await selectInlineOption(actor, screen.getByLabelText("品牌"), "代理品牌-顾家"); const imageFile = new File(["image"], "stock-series.png", { type: "image/png", @@ -690,21 +747,20 @@ describe("NonIntegratedDetailClient", () => { ) as NonIntegratedProductRecord[]; expect(savedProducts[0]?.productDevelopmentKind).toBe("series"); - expect(savedProducts[0]?.spu).toBe("CJ0250"); + expect(savedProducts[0]?.spu).toBe("GBGJ-CJ0250"); + expect(savedProducts[0]?.openingReference.temporaryCode).toBe("GBGJ-CJ0250"); expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列"); expect(savedProducts[0]?.processTemplate).toBe("存量商品"); - expect(savedProducts[0]?.skuBatchConfirmedAt?.["1"]).toEqual( - expect.any(String), - ); + expect(savedProducts[0]?.skuBatchConfirmedAt).toEqual({}); expect(savedProducts[0]?.skuItems).toHaveLength(2); expect(savedProducts[0]?.skuItems.map((item) => item.skuCode)).toEqual([ - "CJ025001", - "CJ025002", + "GBGJ-CJ025001", + "GBGJ-CJ025002", ]); expect(savedProducts[0]?.skuItems[0]).toEqual( expect.objectContaining({ model: "DBT140RQ", - barcode: "CJ025001V01", + barcode: "GBGJ-CJ025001V01", setCount: "1", specName: "140*60*75 胡桃色", size: "140*60*75", @@ -1312,6 +1368,43 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("类目")).toHaveAttribute("readonly"); }); + it("applies the current brand prefix when opening an existing formal SPU", () => { + saveNonIntegratedProduct( + createProductRecord({ + id: "SZ0242-brand-prefix", + spu: "SZ0242", + formalSpuAssigned: true, + brand: "代理品牌-顾家", + productCode: "SZ0242", + openingReference: { + ...createEmptyNonIntegratedOpeningReference(), + productName: "顾家品牌开品", + brand: "代理品牌-顾家", + productCategory: "书桌", + salesCategory: "桌类", + }, + skuItems: [ + createSkuItem({ + id: "sku-brand-prefix-1", + model: "SZ0242K01", + skuCode: "SZ024201", + barcode: "SZ024201V01", + }), + ], + }), + ); + + render( + , + ); + + expect(screen.getByLabelText("SPU")).toHaveValue("GBGJ-SZ0242"); + expect(screen.getByDisplayValue("GBGJ-SZ024201")).toBeInTheDocument(); + }); + it("does not treat a temporary code as formal when only basic info is approved", () => { saveNonIntegratedProduct( createProductRecord({ @@ -2963,12 +3056,12 @@ describe("NonIntegratedDetailClient", () => { ); expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([ - "郑州仓", + "杭州仓", "杭州仓", "", ]); expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([ - "OEM", + "ODM", "ODM", "", ]); @@ -2999,13 +3092,13 @@ describe("NonIntegratedDetailClient", () => { ); expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([ - "郑州仓", + "", "", "", ]); expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([ "", - "ODM", + "", "", ]); }); @@ -3212,8 +3305,8 @@ describe("NonIntegratedDetailClient", () => { expect( screen.queryByTestId("non-integrated-add-sku"), ).not.toBeInTheDocument(); - expect(screen.queryByText("上传资料")).not.toBeInTheDocument(); - expect(screen.queryByText("上传图片")).not.toBeInTheDocument(); + expect(screen.getByText("上传资料").closest("button")).toBeDisabled(); + expect(screen.getByText("上传图片").closest("button")).toBeDisabled(); await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); await actor.click(screen.getByTestId("product-factory-reopen-confirmation")); @@ -3233,6 +3326,238 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByText("上传图片")).toBeInTheDocument(); }); + it("downloads product factory contracts with the shared download progress toast", async () => { + const actor = userEvent.setup(); + const originalFetch = global.fetch; + const fetchMock = jest.fn(async (url: string) => { + if (url.includes("/contracts?")) { + return { + ok: true, + headers: { + get: (name: string) => + name.toLowerCase() === "content-disposition" + ? "attachment; filename*=UTF-8''%E6%9C%88%E7%BB%93%E5%90%88%E5%90%8C_SZ0230.docx" + : null, + }, + blob: async () => + new Blob(["docx"], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }), + }; + } + + return { + ok: true, + json: async () => ({ success: true, data: { names: [] } }), + }; + }); + + Object.defineProperty(global, "fetch", { + configurable: true, + writable: true, + value: fetchMock, + }); + + try { + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-contract-download", + spu: "SZ0230", + purchaser: "采购同事", + purchaseOwner: "采购同事", + skuItems: [ + createSkuItem({ + id: "sku-contract-download-1", + image: "data:image/png;base64,ZmFrZQ==", + barcode: "SZ023001N1280V01", + manufacturer: "兰考华兰家具", + manufacturerCode: "N1280", + }), + ], + factoryRows: [ + { + skuId: "sku-contract-download-1", + confirmed: false, + model: "SZ0230K01", + skuCode: "SZ023001", + setCount: "1", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75 胡桃色", + size: "140/60/75", + color: "胡桃色", + material: "橡胶木", + installMethod: "拆装", + supplierName: "兰考华兰家具", + supplierCode: "N1280", + warehouse: "郑州仓", + minimumOrderQuantity: "50", + taxIncludedUnitPrice: "120", + leadTime: "30天", + assemblyMode: "", + productionType: "常规生产", + installationService: "无", + supplierCategory: "主供", + purchaseOrderMethod: "系统下单", + executeStandard: "GB/T 3324", + }, + ], + }), + ); + + render( + 采购部"]} + />, + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); + const factoryWorkspace = screen.getByTestId("product-factory-workspace"); + expect(within(factoryWorkspace).getByText("预付合同")).toBeInTheDocument(); + expect(within(factoryWorkspace).getByText("月结合同")).toBeInTheDocument(); + expect( + within(factoryWorkspace).getByText("采购标准合同"), + ).toBeInTheDocument(); + expect(within(factoryWorkspace).getByText("图片")).toBeInTheDocument(); + expect(within(factoryWorkspace).getByText("含税单价")).toBeInTheDocument(); + + await actor.click(within(factoryWorkspace).getByText("月结合同")); + + const progressToast = await screen.findByTestId("download-progress-toast"); + expect(progressToast).toHaveTextContent("下载中"); + await waitFor(() => + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes( + "/api/intake/non-integrated/DNZ0001-contract-download/contracts?type=monthly&batch=1", + ), + ), + ).toBe(true), + ); + expect(anchorClickSpy).toHaveBeenCalled(); + } finally { + Object.defineProperty(global, "fetch", { + configurable: true, + writable: true, + value: originalFetch, + }); + } + }); + + it("keeps allowed download actions for purchasing users and hides other workspace downloads", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-purchase-download-restricted", + spu: "SZ0230", + purchaser: "其他采购", + purchaseOwner: "其他采购", + skuItems: [ + createSkuItem({ + id: "sku-purchase-download-restricted-1", + image: "data:image/png;base64,ZmFrZQ==", + barcode: "SZ023001N1280V01", + manufacturer: "兰考华兰家具", + manufacturerCode: "N1280", + }), + ], + factoryRows: [ + { + skuId: "sku-purchase-download-restricted-1", + confirmed: false, + model: "SZ0230K01", + skuCode: "SZ023001", + setCount: "1", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75 胡桃色", + size: "140/60/75", + color: "胡桃色", + material: "橡胶木", + installMethod: "拆装", + supplierName: "兰考华兰家具", + supplierCode: "N1280", + warehouse: "郑州仓", + minimumOrderQuantity: "50", + taxIncludedUnitPrice: "120", + leadTime: "30天", + assemblyMode: "", + productionType: "常规生产", + installationService: "无", + supplierCategory: "主供", + purchaseOrderMethod: "系统下单", + executeStandard: "GB/T 3324", + }, + ], + uploadFiles: [ + { + id: "purchase-restricted-opening-doc", + name: "采购不可下载资料.pdf", + category: "开品参考", + dataUrl: "data:application/pdf;base64,AAAA", + mimeType: "application/pdf", + }, + { + id: "purchase-allowed-product-data-doc", + name: "采购可下载打样资料.pdf", + category: "产品模型", + productDataGroup: "sampling", + dataUrl: "data:application/pdf;base64,BBBB", + mimeType: "application/pdf", + }, + ], + }), + ); + + render( + 采购部"]} + />, + ); + + [ + "开品参考", + "设计卖点", + "企划布局", + "专利申请", + "产品实拍图", + "安装方式", + "包装细节", + "产品限价", + "工厂质检", + "检测报告", + ].forEach((category) => { + expect( + screen.queryByTestId(`document-workspace-trigger-${category}`), + ).not.toBeInTheDocument(); + }); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品模型")); + + expect(screen.getAllByText("采购可下载打样资料.pdf").length).toBeGreaterThan( + 0, + ); + expect(screen.getByRole("button", { name: "批量下载" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "下载" })).toBeInTheDocument(); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); + const factoryWorkspace = screen.getByTestId("product-factory-workspace"); + expect(within(factoryWorkspace).getByText("预付合同")).toBeInTheDocument(); + expect(within(factoryWorkspace).getByText("月结合同")).toBeInTheDocument(); + expect( + within(factoryWorkspace).getByText("采购标准合同"), + ).toBeInTheDocument(); + }); + it("hides redundant fields from the product detail summary", async () => { const actor = userEvent.setup(); @@ -3504,6 +3829,7 @@ describe("NonIntegratedDetailClient", () => { id: "sku-review-factory-option", manufacturer: "兰考华兰家具", manufacturerCode: "N1280", + barcode: "SZ023001N1280V01", size: "140/60/78", material: "橡胶木", color: "朴像胡桃木色", @@ -3524,10 +3850,13 @@ describe("NonIntegratedDetailClient", () => { await actor.click(screen.getByTestId("document-workspace-trigger-评审资料")); const workspace = screen.getByTestId("review-sheet-workspace"); + expect(within(workspace).queryByText("评审单基础信息")).not.toBeInTheDocument(); expect(within(workspace).getByLabelText("产品名称")).toHaveValue(""); expect(within(workspace).getByLabelText("尺寸")).toHaveValue(""); expect(within(workspace).getByLabelText("材质")).toHaveValue(""); expect(within(workspace).getByLabelText("颜色")).toHaveValue(""); + expect(within(workspace).getByLabelText("条码")).toHaveValue(""); + expect(within(workspace).getByLabelText("审样结果")).toHaveValue(""); expect( within(workspace).queryByTestId("review-sheet-product-image"), ).not.toBeInTheDocument(); @@ -3545,6 +3874,285 @@ describe("NonIntegratedDetailClient", () => { expect(within(workspace).getByLabelText("尺寸")).toHaveValue(""); expect(within(workspace).getByLabelText("材质")).toHaveValue(""); expect(within(workspace).getByLabelText("颜色")).toHaveValue(""); + + await selectInlineOption( + actor, + screen.getByLabelText("条码"), + "SZ023001N1280V01", + ); + await selectInlineOption( + actor, + within(workspace).getByLabelText("审样结果"), + "产前样", + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-review-manual-base-fields", + ); + expect(savedProduct?.reviewSheet.barcode).toBe("SZ023001N1280V01"); + expect(savedProduct?.reviewSheet.reviewResult).toBe("产前样"); + }); + }); + + it("locks the confirmed review result but keeps barcode binding editable for no-modification samples", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "SZ0230-review-no-modification-barcode-binding", + spu: "SZ0230", + skuItems: [ + createSkuItem({ + id: "sku-review-no-modification-1", + model: "SZ0230K01", + skuCode: "SZ023001", + manufacturerCode: "N1280", + barcode: "SZ023001N1280V01", + }), + createSkuItem({ + id: "sku-review-no-modification-2", + model: "SZ0230K02", + skuCode: "SZ023002", + manufacturerCode: "N1280", + barcode: "SZ023002N1280V01", + }), + ], + }), + ); + + render( + , + ); + + const workspace = screen.getByTestId("review-sheet-workspace"); + await selectInlineOption( + actor, + within(workspace).getByLabelText("审样结果"), + "无需修改", + ); + + const confirmButton = screen.getByRole("button", { name: "确认评审资料" }); + expect(confirmButton).toBeEnabled(); + await actor.click(confirmButton); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-review-no-modification-barcode-binding", + ); + expect(savedProduct?.reviewSheet.reviewResult).toBe("无需修改"); + expect(savedProduct?.reviewSheet.barcode).toBe(""); + expect(savedProduct?.documentWorkspaceConfirmations?.评审资料).toBe(true); + expect(savedProduct?.documentWorkspaceConfirmations?.成品寄样).toBe(true); + expect(savedProduct?.finishedSampleShipmentRows).toEqual([ + { + skuId: "sku-review-no-modification-1", + sampleQuantity: "0", + shipped: "是", + trackingNumber: "已有样品", + }, + { + skuId: "sku-review-no-modification-2", + sampleQuantity: "0", + shipped: "是", + trackingNumber: "已有样品", + }, + ]); + }); + + expect(within(workspace).getByLabelText("审样结果")).toBeDisabled(); + expect(within(workspace).getByLabelText("条码")).toBeEnabled(); + + await selectInlineOption( + actor, + within(workspace).getByLabelText("条码"), + "SZ023001N1280V01", + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-review-no-modification-barcode-binding", + ); + expect(savedProduct?.reviewSheet.reviewResult).toBe("无需修改"); + expect(savedProduct?.reviewSheet.barcode).toBe("SZ023001N1280V01"); + expect(savedProduct?.documentWorkspaceConfirmations?.评审资料).toBe(true); + expect(savedProduct?.documentWorkspaceConfirmations?.成品寄样).toBe(true); + expect(savedProduct?.finishedSampleShipmentRows).toEqual([ + { + skuId: "sku-review-no-modification-1", + sampleQuantity: "1", + shipped: "是", + trackingNumber: "已有样品", + }, + { + skuId: "sku-review-no-modification-2", + sampleQuantity: "0", + shipped: "是", + trackingNumber: "已有样品", + }, + ]); + }); + }); + + it("supports pasting issue and reference images into sample review cells", async () => { + const actor = userEvent.setup(); + const productId = "SZ0230-review-paste-images"; + const issueId = "review-issue-paste"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "SZ0230", + reviewSheet: { + ...createProductRecord().reviewSheet, + issues: [ + { + id: issueId, + issueImage: "", + issueImageName: "", + issueImages: [], + issueDescription: "issue row", + fixSuggestion: "", + referenceImage: "", + referenceImageName: "", + referenceImages: [], + }, + ], + }, + }), + ); + + render( + , + ); + + await actor.click( + screen.getByTestId("document-workspace-trigger-\u8bc4\u5ba1\u8d44\u6599"), + ); + + const issueImage = new File(["issue"], "issue-paste.png", { + type: "image/png", + }); + fireEvent.paste( + screen.getByTestId(`review-sheet-issue-paste-zone-${issueId}`), + { + clipboardData: { + files: [issueImage], + items: [], + }, + }, + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.reviewSheet.issues[0]?.issueImages).toEqual([ + expect.objectContaining({ + fileName: "issue-paste.png", + url: "data:image/png;base64,dGVzdA==", + isPrimary: true, + }), + ]); + }); + + const referenceImage = new File(["reference"], "reference-paste.webp", { + type: "image/webp", + }); + fireEvent.paste( + screen.getByTestId(`review-sheet-reference-paste-zone-${issueId}`), + { + clipboardData: { + files: [referenceImage], + items: [], + }, + }, + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.reviewSheet.issues[0]?.referenceImages).toEqual([ + expect.objectContaining({ + fileName: "reference-paste.webp", + url: "data:image/webp;base64,dGVzdA==", + isPrimary: true, + }), + ]); + }); + }); + + it("allows deleting the last sample review reference image", async () => { + const actor = userEvent.setup(); + const productId = "SZ0230-review-delete-last-image"; + const issueId = "review-issue-delete-last-image"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "SZ0230", + reviewSheet: { + ...createProductRecord().reviewSheet, + issues: [ + { + id: issueId, + issueImage: "", + issueImageName: "", + issueImages: [], + issueDescription: "keep row visible", + fixSuggestion: "", + referenceImage: "data:image/png;base64,old", + referenceImageName: "old-reference.png", + referenceImages: [ + { + id: "reference-image-delete-last", + url: "data:image/png;base64,old", + fileName: "old-reference.png", + isPrimary: true, + }, + ], + }, + ], + }, + }), + ); + + render( + , + ); + + await actor.click( + screen.getByTestId("document-workspace-trigger-\u8bc4\u5ba1\u8d44\u6599"), + ); + await actor.click( + screen.getByTestId(`review-sheet-reference-image-manager-${issueId}`), + ); + + const manager = screen.getByTestId( + `review-sheet-reference-image-manager-paste-zone-${issueId}`, + ); + await actor.click(within(manager).getByRole("button", { name: "删除" })); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + const issue = savedProduct?.reviewSheet.issues[0]; + expect(issue?.referenceImages).toEqual([]); + expect(issue?.referenceImage).toBe(""); + expect(issue?.referenceImageName).toBe(""); + }); }); it("renders sample measurement rows from SKU data and persists purchaser fields", async () => { @@ -3884,9 +4492,7 @@ describe("NonIntegratedDetailClient", () => { it("lets purchasing users edit only purchasing document nodes", async () => { const actor = userEvent.setup(); - const openingReferenceCategory = "开品参考"; const sampleMeasurementCategory = "样包测量"; - const productLimitCategory = "产品限价"; saveNonIntegratedProduct( createProductRecord({ @@ -3912,7 +4518,6 @@ describe("NonIntegratedDetailClient", () => { { />, ); - expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument(); - expect(screen.getByLabelText("开品名称")).toHaveAttribute("readonly"); + expect( + screen.queryByTestId("document-workspace-trigger-开品参考"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("document-workspace-trigger-产品限价"), + ).not.toBeInTheDocument(); await actor.click( screen.getByTestId(`document-workspace-trigger-${sampleMeasurementCategory}`), @@ -3940,16 +4549,6 @@ describe("NonIntegratedDetailClient", () => { ); expect(savedProduct?.sampleMeasurementRows?.[0]?.lengthCm).toBe("120"); }); - - await actor.click( - screen.getByTestId(`document-workspace-trigger-${productLimitCategory}`), - ); - - expect( - screen.getByTestId( - "product-limit-expectedDailyLimitPrice-sku-purchasing-permission-1", - ), - ).toHaveAttribute("readonly"); }); it("lets planning users edit opening reference, planning layout, and product limit nodes", async () => { @@ -4297,27 +4896,21 @@ describe("NonIntegratedDetailClient", () => { }); expect(screen.getByRole("button", { name: "确认推送" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "批量确认推送" })).toBeDisabled(); expect(screen.queryByText("已录入")).not.toBeInTheDocument(); expect(screen.queryByText("已推送")).not.toBeInTheDocument(); const confirmPricingButton = screen.getByRole("button", { name: "确认核价", }); - expect(confirmPricingButton).toBeEnabled(); + expect(confirmPricingButton).toBeDisabled(); - await actor.click(confirmPricingButton); - - await waitFor(() => { - const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); - expect(savedProduct?.documentWorkspaceConfirmations?.产品核价).toBe(true); - expect(savedProduct?.documentWorkspaceApprovedAt?.产品核价).toEqual( - expect.any(String), - ); - }); - - expect(screen.getByPlaceholderText("0")).toHaveAttribute("readonly"); + const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); + expect(savedProduct?.documentWorkspaceConfirmations?.产品核价).not.toBe(true); + expect(savedProduct?.documentWorkspaceApprovedAt?.产品核价).toBeUndefined(); + expect(screen.getByPlaceholderText("0")).not.toHaveAttribute("readonly"); expect(screen.queryByPlaceholderText("请输入商品标签")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "减少成本价" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "减少成本价" })).toBeEnabled(); }); it("lets only admins reopen confirmed product factory rows before sample measurement is approved", async () => { @@ -4579,7 +5172,7 @@ describe("NonIntegratedDetailClient", () => { ).not.toBeInTheDocument(); }); - it("treats stock product SKU batches as confirmed without a saved confirmation timestamp", async () => { + it("keeps stock-created product detail SKU content editable without auto confirmation", async () => { const actor = userEvent.setup(); const planningLayoutCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[5]; @@ -4612,7 +5205,9 @@ describe("NonIntegratedDetailClient", () => { ); await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); - expect(screen.getByText("已确认SKU信息")).toBeInTheDocument(); + expect(screen.queryByText("已确认SKU信息")).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("STOCKK01")).not.toHaveAttribute("readonly"); + expect(screen.getByDisplayValue("STOCK001V01")).not.toHaveAttribute("readonly"); await actor.click( screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`), @@ -5285,10 +5880,14 @@ describe("NonIntegratedDetailClient", () => { />, ); + expect(screen.getByTestId("document-workspace-trigger-评审资料")).toHaveAttribute( + "data-has-files", + "false", + ); expect( - within(screen.getByTestId("document-workspace-trigger-评审资料")).getByText( - "0", - ), + within( + screen.getByTestId("document-workspace-trigger-评审资料"), + ).getByLabelText("未填"), ).toBeInTheDocument(); emptyRender.unmount(); @@ -5332,10 +5931,14 @@ describe("NonIntegratedDetailClient", () => { />, ); + expect(screen.getByTestId("document-workspace-trigger-评审资料")).toHaveAttribute( + "data-has-files", + "true", + ); expect( - within(screen.getByTestId("document-workspace-trigger-评审资料")).getByText( - "1", - ), + within( + screen.getByTestId("document-workspace-trigger-评审资料"), + ).getByLabelText("已填但待确认"), ).toBeInTheDocument(); expect(screen.getByTestId("document-workspace-trigger-评审资料")).toHaveAttribute( "data-pending-confirmation", @@ -5389,7 +5992,7 @@ describe("NonIntegratedDetailClient", () => { expect( within( screen.getByTestId("document-workspace-trigger-评审资料"), - ).getByText("1"), + ).getByLabelText("已填但待确认"), ).toBeInTheDocument(); confirmSpy.mockReturnValue(true); @@ -5399,7 +6002,7 @@ describe("NonIntegratedDetailClient", () => { expect( within( screen.getByTestId("document-workspace-trigger-评审资料"), - ).getByText("2"), + ).getByLabelText("已填但待确认"), ).toBeInTheDocument(); } finally { confirmSpy.mockRestore(); @@ -5808,6 +6411,491 @@ describe("NonIntegratedDetailClient", () => { ); }); + it("applies SKU material to the whole model and supports node material overrides", async () => { + const actor = userEvent.setup(); + const walnutFinish = createMaterialFinish({ + id: "finish-walnut", + name: "walnut oak", + code: "WALNUT-OAK", + categoryName: "wood veneer", + color: "#8b5a2b", + }); + const blackFinish = createMaterialFinish({ + id: "finish-black", + name: "black metal", + code: "BLACK-METAL", + categoryName: "metal", + color: "#111111", + metalness: 0.8, + roughness: 0.35, + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-3d-material-assignment", + skuItems: [ + createSkuItem({ + id: "sku-3d-material-assignment", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + color: "walnut", + material: "solid wood", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }), + ]), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + const getMaterialAssignments = () => + getProductPreviewProps().materialAssignments as + | Array<{ nodeName: string; material: { id: string } }> + | undefined; + + await waitFor(() => + expect(getProductPreviewProps().modelUrl).toBeTruthy(), + ); + expect(screen.queryByTestId("model-material-panel")).not.toBeInTheDocument(); + + act(() => { + ( + getProductPreviewProps().onModelNodesChange as ( + nodeNames: string[], + ) => void + )(["table-top", "leg"]); + }); + act(() => { + ( + getProductPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + rawNodeName?: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "table-top-surface", + rawNodeName: "table-top-surface", + clientX: 180, + clientY: 180, + }); + }); + + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ id: "finish-walnut" }), + }), + expect.objectContaining({ + nodeName: "leg", + material: expect.objectContaining({ id: "finish-walnut" }), + }), + ]), + ); + expect(screen.getByTestId("model-auto-material-label")).toHaveTextContent( + "walnut oak", + ); + + act(() => { + ( + getProductPreviewProps().onNodeSelect as (payload: { + nodeName: string; + }) => void + )({ nodeName: "leg" }); + }); + + await waitFor(() => + expect(screen.getByTestId("model-node-material-select")).not.toBeDisabled(), + ); + await selectInlineOption( + actor, + screen.getByTestId("model-node-material-select"), + "finish-black", + ); + + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ id: "finish-walnut" }), + }), + expect.objectContaining({ + nodeName: "leg", + material: expect.objectContaining({ id: "finish-black" }), + }), + ]), + ); + await waitFor(() => + expect( + getNonIntegratedProductById( + "DNZ0001-3d-material-assignment", + )?.skuItems[0]?.model3dNodeMaterialIds, + ).toEqual({ leg: "finish-black" }), + ); + }); + + it("applies one material change to multiple selected 3D nodes", async () => { + const actor = userEvent.setup(); + const walnutFinish = createMaterialFinish({ + id: "finish-multi-walnut", + name: "multi walnut", + code: "MULTI-WALNUT", + categoryName: "wood veneer", + color: "#8b5a2b", + }); + const blackFinish = createMaterialFinish({ + id: "finish-multi-black", + name: "multi black", + code: "MULTI-BLACK", + categoryName: "metal", + color: "#111111", + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-3d-multi-node-material", + skuItems: [ + createSkuItem({ + id: "sku-3d-multi-node-material", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + color: "walnut", + material: "solid wood", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }), + ]), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + const getMaterialAssignments = () => + getProductPreviewProps().materialAssignments as + | Array<{ nodeName: string; material: { id: string } }> + | undefined; + + await waitFor(() => + expect(getProductPreviewProps().modelUrl).toBeTruthy(), + ); + + act(() => { + ( + getProductPreviewProps().onModelNodesChange as ( + nodeNames: string[], + ) => void + )(["table-top", "leg", "drawer"]); + }); + act(() => { + ( + getProductPreviewProps().onNodeSelect as (payload: { + nodeName: string; + rawNodeName?: string | null; + candidateNodeName?: string | null; + appendToSelection?: boolean; + hasRaycastHit?: boolean; + }) => void + )({ + nodeName: "table-top", + rawNodeName: "table-top", + candidateNodeName: "table-top", + appendToSelection: false, + hasRaycastHit: true, + }); + }); + await waitFor(() => + expect(getProductPreviewProps().selectedNodeNames).toEqual(["table-top"]), + ); + act(() => { + ( + getProductPreviewProps().onNodeSelect as (payload: { + nodeName: string; + rawNodeName?: string | null; + candidateNodeName?: string | null; + appendToSelection?: boolean; + hasRaycastHit?: boolean; + }) => void + )({ + nodeName: "leg", + rawNodeName: "leg", + candidateNodeName: "leg", + appendToSelection: true, + hasRaycastHit: true, + }); + }); + + await waitFor(() => { + expect(getProductPreviewProps().selectedNodeNames).toEqual([ + "table-top", + "leg", + ]); + expect(getProductPreviewProps().selectedNodeName).toBe("leg"); + }); + + act(() => { + ( + getProductPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "table-top", + clientX: 180, + clientY: 180, + }); + }); + + await selectInlineOption( + actor, + screen.getByTestId("model-node-material-select"), + "finish-multi-black", + ); + + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ id: "finish-multi-black" }), + }), + expect.objectContaining({ + nodeName: "leg", + material: expect.objectContaining({ id: "finish-multi-black" }), + }), + expect.objectContaining({ + nodeName: "drawer", + material: expect.objectContaining({ id: "finish-multi-walnut" }), + }), + ]), + ); + await waitFor(() => + expect( + getNonIntegratedProductById( + "DNZ0001-3d-multi-node-material", + )?.skuItems[0]?.model3dNodeMaterialIds, + ).toEqual({ + "table-top": "finish-multi-black", + leg: "finish-multi-black", + }), + ); + }); + + it("opens model render parameters from node context menu and applies UV settings", async () => { + const actor = userEvent.setup(); + const walnutFinish = createMaterialFinish({ + id: "finish-uv-walnut", + name: "walnut oak", + code: "WALNUT-OAK", + categoryName: "wood veneer", + color: "#8b5a2b", + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-3d-uv-settings", + skuItems: [ + createSkuItem({ + id: "sku-3d-uv-settings", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + color: "walnut", + material: "solid wood", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }), + ]), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + const getMaterialAssignments = () => + getProductPreviewProps().materialAssignments as + | Array<{ + nodeName: string; + material: { id: string; textureRepeatU?: number }; + }> + | undefined; + + await waitFor(() => + expect(getProductPreviewProps().modelUrl).toBeTruthy(), + ); + + act(() => { + ( + getProductPreviewProps().onModelNodesChange as ( + nodeNames: string[], + ) => void + )(["table-top"]); + }); + act(() => { + ( + getProductPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "table-top", + clientX: 180, + clientY: 180, + }); + }); + + await actor.click(await screen.findByText("渲染参数")); + expect( + await screen.findByTestId("model-uv-settings-panel"), + ).toBeInTheDocument(); + fireEvent.change(screen.getByTestId("model-uv-textureRepeatU"), { + target: { value: "2" }, + }); + + await waitFor(() => + expect( + getNonIntegratedProductById("DNZ0001-3d-uv-settings")?.skuItems[0] + ?.model3dUvConfig, + ).toEqual({ + version: 1, + nodes: { + "table-top": { + textureRepeatU: 2, + }, + }, + }), + ); + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ + id: "finish-uv-walnut", + textureRepeatU: 2, + }), + }), + ]), + ); + }); + + it("shows model material controls only to assigned designers or admins", async () => { + const materialFinish = createMaterialFinish({ + id: "finish-designer-only", + name: "walnut oak", + }); + const product = createProductRecord({ + id: "DNZ0001-3d-material-permission", + designer: "Alice", + openingReference: { + ...createEmptyNonIntegratedOpeningReference(), + designer: "Alice", + structureDesigner: "Bob", + }, + skuItems: [ + createSkuItem({ + id: "sku-3d-material-permission", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }); + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([product]), + ); + + const deniedRender = render( + , + ); + await waitFor(() => + expect(modelPreviewPanelMock).toHaveBeenCalled(), + ); + const getLatestProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + expect(getLatestProductPreviewProps().onNodeContextMenu).toBeUndefined(); + deniedRender.unmount(); + + render( + , + ); + await waitFor(() => + expect(getLatestProductPreviewProps().onNodeContextMenu).toEqual( + expect.any(Function), + ), + ); + }); + it("renders packaging style tabs from SKU models", async () => { const actor = userEvent.setup(); const createStyleDetail = (styleKey: string) => ({ @@ -5857,6 +6945,231 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByTestId("packaging-workspace")).toHaveTextContent( "当前款式:SZ0230K01", ); + expect( + screen.getByTestId("packaging-workspace-model-upload-button"), + ).toHaveTextContent("上传模型"); + expect( + screen.getByTestId("packaging-workspace-model-empty-upload-button"), + ).toHaveTextContent("上传模型"); + }); + + it("applies packaging 3D material controls and render parameters", async () => { + const actor = userEvent.setup(); + const walnutFinish = createMaterialFinish({ + id: "finish-packaging-walnut", + name: "walnut packaging", + code: "WALNUT-PACKAGING", + categoryName: "wood veneer", + color: "#8b5a2b", + }); + const blackFinish = createMaterialFinish({ + id: "finish-packaging-black", + name: "black packaging", + code: "BLACK-PACKAGING", + categoryName: "metal", + color: "#111111", + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-packaging-3d-material", + skuItems: [ + createSkuItem({ + id: "sku-packaging-3d-material", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + color: "walnut", + material: "solid wood", + }), + ], + styleDetails: [ + { + styleKey: "K01", + installMethod: "", + installSteps: "", + packageDetail: "", + craftDetail: "", + model3dFileName: "", + model3dDataUrl: "", + outerPackagingType: "", + innerPackagingItems: [], + packagingModel3dFileName: "packaging.glb", + packagingModel3dDataUrl: "data:model/gltf-binary;base64,cGFjaw==", + }, + ], + }), + ]), + ); + + render( + , + ); + + const packagingTrigger = await waitFor(() => { + const trigger = Array.from( + document.querySelectorAll( + '[data-testid^="document-workspace-trigger-"]', + ), + ).find((button) => button.textContent?.includes("包装细节")); + expect(trigger).toBeDefined(); + return trigger!; + }); + await actor.click(packagingTrigger); + + const getPackagingPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "packaging.glb"); + expect(props).toBeDefined(); + return props!; + }; + const getMaterialAssignments = () => + getPackagingPreviewProps().materialAssignments as + | Array<{ + nodeName: string; + material: { id?: string; textureRepeatU?: number }; + }> + | undefined; + + await waitFor(() => + expect(getPackagingPreviewProps().modelUrl).toBeTruthy(), + ); + expect(getPackagingPreviewProps().enableTranslateGizmo).toBe(true); + expect(getPackagingPreviewProps().gizmoSize).toBe(0.98); + const createObjectUrlMock = window.URL.createObjectURL as jest.Mock; + createObjectUrlMock.mockClear(); + + act(() => { + ( + getPackagingPreviewProps().onGizmoInteractionStart as ( + nodeName: string, + ) => void + )("carton-side"); + ( + getPackagingPreviewProps().onNodePositionOffsetChange as (payload: { + nodeName: string; + offset: [number, number, number]; + }) => void + )({ + nodeName: "carton-side", + offset: [1200, 0, 0], + }); + }); + expect( + getNonIntegratedProductById( + "DNZ0001-packaging-3d-material", + )?.styleDetails[0]?.packagingModel3dNodeOffsets, + ).toEqual({}); + act(() => { + ( + getPackagingPreviewProps().onGizmoInteractionEnd as ( + nodeName: string, + ) => void + )("carton-side"); + }); + await waitFor(() => + expect( + getNonIntegratedProductById( + "DNZ0001-packaging-3d-material", + )?.styleDetails[0]?.packagingModel3dNodeOffsets, + ).toEqual({ "carton-side": [1200, 0, 0] }), + ); + expect(createObjectUrlMock).not.toHaveBeenCalled(); + + act(() => { + ( + getPackagingPreviewProps().onModelNodesChange as ( + nodeNames: string[], + ) => void + )(["carton-top", "carton-side"]); + }); + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "carton-top", + material: expect.objectContaining({ id: "finish-packaging-walnut" }), + }), + expect.objectContaining({ + nodeName: "carton-side", + material: expect.objectContaining({ id: "finish-packaging-walnut" }), + }), + ]), + ); + + act(() => { + ( + getPackagingPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "carton-side", + clientX: 180, + clientY: 180, + }); + }); + expect(screen.getByTestId("model-auto-material-label")).toHaveTextContent( + "walnut packaging", + ); + + await selectInlineOption( + actor, + screen.getByTestId("model-node-material-select"), + "finish-packaging-black", + ); + + await waitFor(() => + expect( + getNonIntegratedProductById( + "DNZ0001-packaging-3d-material", + )?.styleDetails[0]?.packagingModel3dNodeMaterialIds, + ).toEqual({ "carton-side": "finish-packaging-black" }), + ); + + act(() => { + ( + getPackagingPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "carton-side", + clientX: 180, + clientY: 180, + }); + }); + await actor.click( + await screen.findByText((content) => + /参数|鍙傛暟/.test(content), + ), + ); + fireEvent.change(screen.getByTestId("model-uv-textureRepeatU"), { + target: { value: "2" }, + }); + + await waitFor(() => + expect( + getNonIntegratedProductById( + "DNZ0001-packaging-3d-material", + )?.styleDetails[0]?.packagingModel3dUvConfig, + ).toEqual({ + version: 1, + nodes: { + "carton-side": { + textureRepeatU: 2, + }, + }, + }), + ); }); it("keeps the nine-view tab selectable when only detail photos exist", async () => { 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 afbbfe2a..a8a09374 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import JSZip from "jszip"; import type { ChangeEvent, + ClipboardEvent, ComponentProps, ComponentType, PointerEvent as ReactPointerEvent, @@ -26,7 +27,24 @@ import { ArrowLeft, Ban, ChevronDown, Plus, Search, X } from "lucide-react"; import CameraScannerModal from "@/components/mobile/CameraScannerModal"; import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel"; import Button from "@/components/ui/Button"; +import { + calculateMenuPosition, + ContextMenu, + type ContextMenuGroup, + type ContextMenuItem, +} from "@/components/ui/ContextMenu"; import type { NodePositionOffset, NodeRotationState } from "@/lib/3d/engine"; +import type { MaterialConfig } from "@/lib/3d/material-manager"; +import { toMaterialConfig } from "@/lib/material-config"; +import { + applyAssetVariantUvConfigToMaterial, + getAssetVariantUvSurfaceConfig, + normalizeAssetVariantUvConfig, + updateAssetVariantUvConfig, + type AssetVariantUvConfig, + type AssetVariantUvProjectionAxis, + type AssetVariantUvSurfaceConfig, +} from "@/lib/asset-variant-render-config"; import type { DocumentWorkspaceStructuredPreview, PresentationPreviewData, @@ -133,6 +151,7 @@ import { type NonIntegratedSkuExportRow, } from "@/lib/non-integrated-sku-export"; import { fetchSubstrates } from "@/lib/open-product/platform-api"; +import type { MaterialDTO } from "@/server/services"; import RawMaterialLibraryPanel from "@/app/intake/RawMaterialLibraryPanel"; import { SPU_RULE_LIBRARY_UPDATED_EVENT, @@ -245,6 +264,7 @@ import { canEditNonIntegratedWorkspace, isCurrentUserNonIntegratedPurchaseOwner, isIntakeNonIntegratedFullEditor, + isIntakeNonIntegratedSupplyChainUser, type IntakeNonIntegratedPermissionUser, } from "@/lib/non-integrated-permissions"; @@ -307,6 +327,12 @@ type FinishedSampleShipmentColumnWidthKey = | "shipped" | "trackingNumber"; type ProductFactoryTabKey = "factoryInfo" | "skuInfo"; +type ProductFactoryContractType = + | "prepaid" + | "monthly" + | "standard-jianhua" + | "standard-jianxin" + | "standard-jianyue"; type UploadCategoryKey = | "未分类" | "开品参考" @@ -391,12 +417,14 @@ const PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS = ]); const MODEL_SHARED_PRODUCT_FACTORY_KEYS = new Set([ "warehouse", - "minimumOrderQuantity", "productionType", "installationService", "supplierCategory", "purchaseOrderMethod", ]); +const MODEL_AUTOFILL_PRODUCT_FACTORY_KEYS = new Set([ + "minimumOrderQuantity", +]); const SKU_COLUMN_MAX_WIDTH = 520; function normalizeSkuColumnWidth( @@ -665,23 +693,43 @@ type StyleDetailForm = { model3dDataUrl: string; model3dNodeOffsets: PreviewNodeOffsetMap; model3dNodeRotations: PreviewNodeRotationMap; + model3dNodeMaterialIds: PreviewNodeMaterialIdMap; + model3dUvConfig: AssetVariantUvConfig | null; outerPackagingType: string; innerPackagingItems: NonIntegratedPackagingInnerItem[]; packagingModel3dFileName: string; packagingModel3dDataUrl: string; packagingModel3dNodeOffsets: PreviewNodeOffsetMap; packagingModel3dNodeRotations: PreviewNodeRotationMap; + packagingModel3dNodeMaterialIds: PreviewNodeMaterialIdMap; + packagingModel3dUvConfig: AssetVariantUvConfig | null; }; type PreviewNodeOffsetMap = Record; type PreviewNodeRotationMap = Record; +type PreviewNodeMaterialIdMap = Record; type PreviewNodeTransformState = { offsets: PreviewNodeOffsetMap; rotations: PreviewNodeRotationMap; }; +type PreviewUvEditorFormState = { + textureRepeat: string; + textureRepeatU: string; + textureRepeatV: string; + textureRotation: string; + textureSizeMm: string; + textureProjectionAxis: "" | AssetVariantUvProjectionAxis; +}; const EMPTY_PREVIEW_NODE_OFFSET_MAP: PreviewNodeOffsetMap = {}; const EMPTY_PREVIEW_NODE_ROTATION_MAP: PreviewNodeRotationMap = {}; +const UV_PROJECTION_AXIS_OPTIONS = [ + { value: "", label: "自动" }, + { value: "x", label: "X 轴" }, + { value: "y", label: "Y 轴" }, + { value: "z", label: "Z 轴" }, +] satisfies Array<{ value: "" | AssetVariantUvProjectionAxis; label: string }>; + function clonePreviewNodeOffsetMap( source: PreviewNodeOffsetMap, ): PreviewNodeOffsetMap { @@ -713,6 +761,133 @@ function clonePreviewNodeTransformState( }; } +function buildSkuContentRevisionSignature({ + skuItems, + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, +}: { + skuItems: NonIntegratedSkuItem[]; + skuBatchCount: number; + skuBatchFlowNos: Record; + skuBatchChangeTypes: Record; +}) { + return JSON.stringify({ + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, + skuItems: skuItems.map((item) => ({ + id: item.id, + skuSequence: item.skuSequence, + batchNo: item.batchNo, + packageGroupId: item.packageGroupId, + packageSuffix: item.packageSuffix, + image: item.image, + imageName: item.imageName, + model: item.model, + skuCode: item.skuCode, + setCount: item.setCount, + specName: item.specName, + barcode: item.barcode, + size: item.size, + color: item.color, + material: item.material, + boardMaterialType: item.boardMaterialType ?? "", + versionNo: item.versionNo, + attributeCode: item.attributeCode, + drawingId: item.drawingId, + installMethod: item.installMethod, + executeStandard: item.executeStandard, + nationalStandardCompliant: item.nationalStandardCompliant, + manufacturer: item.manufacturer, + manufacturerCode: item.manufacturerCode, + salesStatus: item.salesStatus, + tagMaterial: item.tagMaterial, + componentDetails: item.componentDetails ?? [], + source: item.source ?? "", + sourceTable: item.sourceTable ?? "", + sourceSkuId: item.sourceSkuId ?? "", + contentLocked: item.contentLocked === true, + })), + }); +} + +function formatPreviewUvNumber(value: number | null | undefined) { + return typeof value === "number" && Number.isFinite(value) ? String(value) : ""; +} + +function formatPreviewUvRotationDegrees(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return ""; + } + + const degrees = (value * 180) / Math.PI; + return Number.isInteger(degrees) + ? String(degrees) + : String(Number(degrees.toFixed(2))); +} + +function createPreviewUvEditorFormState( + config: AssetVariantUvSurfaceConfig | null | undefined, +): PreviewUvEditorFormState { + return { + textureRepeat: formatPreviewUvNumber(config?.textureRepeat), + textureRepeatU: formatPreviewUvNumber(config?.textureRepeatU), + textureRepeatV: formatPreviewUvNumber(config?.textureRepeatV), + textureRotation: formatPreviewUvRotationDegrees(config?.textureRotation), + textureSizeMm: formatPreviewUvNumber(config?.textureSizeMm), + textureProjectionAxis: config?.textureProjectionAxis ?? "", + }; +} + +function parsePreviewUvEditorFormState( + state: PreviewUvEditorFormState, +): AssetVariantUvSurfaceConfig | null { + const parseField = (value: string) => { + const normalized = value.trim(); + if (!normalized) return undefined; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; + }; + + const textureRepeat = parseField(state.textureRepeat); + const textureRepeatU = parseField(state.textureRepeatU); + const textureRepeatV = parseField(state.textureRepeatV); + const textureRotationDegrees = parseField(state.textureRotation); + const textureRotation = + textureRotationDegrees === undefined + ? undefined + : (textureRotationDegrees * Math.PI) / 180; + const textureSizeMm = parseField(state.textureSizeMm); + const textureProjectionAxis = + state.textureProjectionAxis === "x" || + state.textureProjectionAxis === "y" || + state.textureProjectionAxis === "z" + ? state.textureProjectionAxis + : undefined; + + if ( + textureRepeat === undefined && + textureRepeatU === undefined && + textureRepeatV === undefined && + textureRotation === undefined && + textureSizeMm === undefined && + textureProjectionAxis === undefined + ) { + return null; + } + + return { + ...(textureRepeat !== undefined ? { textureRepeat } : {}), + ...(textureRepeatU !== undefined ? { textureRepeatU } : {}), + ...(textureRepeatV !== undefined ? { textureRepeatV } : {}), + ...(textureRotation !== undefined ? { textureRotation } : {}), + ...(textureSizeMm !== undefined ? { textureSizeMm } : {}), + ...(textureProjectionAxis !== undefined ? { textureProjectionAxis } : {}), + }; +} + function arePreviewNodeOffsetMapsEqual( left: PreviewNodeOffsetMap, right: PreviewNodeOffsetMap, @@ -840,6 +1015,10 @@ const OPENING_REFERENCE_DEMAND_TYPE_OPTIONS = [ "设计需求", ] as const; const REVIEW_METHOD_OPTIONS = ["现场", "远程视频"] as const; +const REVIEW_RESULT_OPTIONS = ["无需修改", "产前样"] as const; +const REVIEW_RESULT_NO_MODIFICATION = "无需修改"; +const FINISHED_SAMPLE_SHIPMENT_AUTO_SHIPPED = "是"; +const FINISHED_SAMPLE_SHIPMENT_EXISTING_SAMPLE_TRACKING = "已有样品"; const REVIEW_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "评审资料"; const PATENT_APPLICATION_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "专利申请"; @@ -859,6 +1038,25 @@ const PRODUCT_LIMIT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "产品限价"; const PLANNING_LAYOUT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY; +const PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES = + new Set([ + "开品参考", + "设计卖点", + PLANNING_LAYOUT_WORKSPACE_CATEGORY, + PATENT_APPLICATION_WORKSPACE_CATEGORY, + "产品实拍图", + INSTALLATION_WORKSPACE_CATEGORY, + PACKAGING_WORKSPACE_CATEGORY, + PRODUCT_LIMIT_WORKSPACE_CATEGORY, + "工厂质检", + DETECTION_WORKSPACE_CATEGORY, + ]); + +function isPurchaseHiddenDocumentWorkspaceCategory( + category: DocumentWorkspaceCategory, +) { + return PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category); +} function getUniqueNonEmptyStrings( values: ReadonlyArray, @@ -918,6 +1116,73 @@ function getPlanningLayoutHundredPriceBand(value: string | undefined) { return `${lower}-${lower + 100}`; } +function normalizeMaterialMatchText(value: string | null | undefined) { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[\s_\-—()()【】\[\]{}·.,,。/\\|]+/g, ""); +} + +function normalizePersonPermissionName(value: string | null | undefined) { + return (value ?? "").trim().replace(/\s+/g, ""); +} + +function getMaterialFinishSearchText(finish: MaterialDTO) { + return normalizeMaterialMatchText( + [finish.name, finish.code, finish.categoryName].filter(Boolean).join(" "), + ); +} + +function scoreMaterialFinishMatch( + finish: MaterialDTO, + color: string, + material: string, +) { + const finishName = normalizeMaterialMatchText(finish.name); + const finishSearchText = getMaterialFinishSearchText(finish); + const colorKey = normalizeMaterialMatchText(color); + const materialTokens = material + .split(/[\/,,、\s]+/) + .map((token) => normalizeMaterialMatchText(token)) + .filter((token) => token.length >= 2); + let score = 0; + + if (colorKey) { + if (finishName === colorKey) { + score += 120; + } else if (finishName.includes(colorKey) || colorKey.includes(finishName)) { + score += 90; + } else if (finishSearchText.includes(colorKey)) { + score += 70; + } + } + + materialTokens.forEach((token) => { + if (finishSearchText.includes(token)) { + score += 12; + } + }); + + return score; +} + +function findBestMaterialFinish( + finishes: MaterialDTO[], + color: string, + material: string, +) { + const ranked = finishes + .filter((finish) => finish.isActive !== false) + .map((finish) => ({ + finish, + score: scoreMaterialFinishMatch(finish, color, material), + })) + .filter((entry) => entry.score >= 70) + .sort((left, right) => right.score - left.score); + + return ranked[0]?.finish ?? null; +} + const FACTORY_QUALITY_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "工厂质检"; const PACKAGING_WORKSPACE_PANEL_OPTIONS: Array<{ @@ -1484,7 +1749,6 @@ const COMPONENT_SKU_CATEGORY_ORDER = new Map( const SKU_TOOLBAR_BUTTON_BASE_CLASS = "inline-flex h-8 min-h-8 shrink-0 items-center justify-center rounded-apple-lg border px-3 text-xs font-apple-medium transition disabled:cursor-not-allowed"; -const SKU_TOOLBAR_STATUS_CLASS = `${SKU_TOOLBAR_BUTTON_BASE_CLASS} cursor-default select-none border-apple-separator bg-apple-bg-secondary text-apple-label-secondary`; const SKU_PRIMARY_ACTION_BUTTON_CLASS = "!h-8 !min-h-8 !rounded-apple-lg !border-zinc-950 !bg-zinc-950 !bg-none !px-3 !py-0 !text-xs !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 DETAIL_PRIMARY_ADD_BUTTON_CLASS = @@ -1540,6 +1804,26 @@ const PRODUCT_FACTORY_TAB_OPTIONS: { { key: "skuInfo", label: "SKU信息" }, ]; +const PRODUCT_FACTORY_DIRECT_CONTRACT_OPTIONS: { + key: Extract; + label: string; +}[] = [ + { key: "prepaid", label: "预付合同" }, + { key: "monthly", label: "月结合同" }, +]; + +const PRODUCT_FACTORY_STANDARD_CONTRACT_OPTIONS: { + key: Extract< + ProductFactoryContractType, + "standard-jianhua" | "standard-jianxin" | "standard-jianyue" + >; + label: string; +}[] = [ + { key: "standard-jianhua", label: "简华模板" }, + { key: "standard-jianxin", label: "简心模板" }, + { key: "standard-jianyue", label: "简悦模板" }, +]; + const OPENING_REFERENCE_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "开品参考"; const PROJECT_REVIEW_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = @@ -1639,6 +1923,7 @@ const DEFAULT_DOCUMENT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = const PRODUCT_PHOTO_CATEGORY: DocumentWorkspaceCategory = "产品实拍图"; const AUTO_SAVE_DELAY_MS = 500; const STOCK_PRODUCT_LABEL = "\u5b58\u91cf\u5546\u54c1"; +const PRODUCT_PRICING_JST_PUSH_ENABLED = false; function getChangedWorkflowNodeCategories( previousSignatures: Record | null | undefined, @@ -2089,6 +2374,54 @@ function getDocumentWorkspaceShortcutCountClass(hasFiles: boolean) { : "bg-[#f8fafc] text-[#6f7c89]"; } +function getDocumentWorkspaceSidebarButtonClass(isActive: boolean) { + return `relative grid min-h-[34px] grid-cols-[minmax(0,1fr)_22px] items-center gap-2 rounded-apple-lg border px-3 py-2 text-left transition ${ + isActive + ? "border-zinc-950 bg-zinc-950 text-white hover:border-zinc-800 hover:bg-zinc-800" + : "border-transparent bg-apple-bg-primary text-apple-label-primary hover:bg-apple-bg-secondary/40" + }`; +} + +function DocumentWorkspaceSidebarStatusIcon({ + hasContent, + needsConfirmation, +}: { + hasContent: boolean; + needsConfirmation: boolean; +}) { + if (!hasContent) { + return ( + + ); + } + + if (needsConfirmation) { + return ( + + + + ); + } + + return ( + + ✓ + + ); +} + function isProductPhotoNineViewSlot( value: string | undefined | null, ): value is ProductPhotoNineViewSlot { @@ -2627,12 +2960,16 @@ const EMPTY_STYLE_DETAIL: StyleDetailForm = { model3dDataUrl: "", model3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: {}, + model3dUvConfig: null, outerPackagingType: "", innerPackagingItems: [], packagingModel3dFileName: "", packagingModel3dDataUrl: "", packagingModel3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, packagingModel3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + packagingModel3dNodeMaterialIds: {}, + packagingModel3dUvConfig: null, }; function createEmptyPackagingInnerItem(): NonIntegratedPackagingInnerItem { @@ -2740,6 +3077,8 @@ function createEmptySkuItem(): NonIntegratedSkuItem { model3dDataUrl: "", model3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: {}, + model3dUvConfig: null, }; } @@ -3609,6 +3948,8 @@ function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { detail.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: detail.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: detail.model3dNodeMaterialIds ?? {}, + model3dUvConfig: normalizeAssetVariantUvConfig(detail.model3dUvConfig), outerPackagingType: detail.outerPackagingType ?? "", innerPackagingItems: (detail.innerPackagingItems ?? []).map((item) => ({ ...item, @@ -3619,6 +3960,11 @@ function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { detail.packagingModel3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, packagingModel3dNodeRotations: detail.packagingModel3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + packagingModel3dNodeMaterialIds: + detail.packagingModel3dNodeMaterialIds ?? {}, + packagingModel3dUvConfig: normalizeAssetVariantUvConfig( + detail.packagingModel3dUvConfig, + ), }; } @@ -3635,12 +3981,16 @@ function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { model3dDataUrl: "", model3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: {}, + model3dUvConfig: null, outerPackagingType: "", innerPackagingItems: [], packagingModel3dFileName: "", packagingModel3dDataUrl: "", packagingModel3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, packagingModel3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + packagingModel3dNodeMaterialIds: {}, + packagingModel3dUvConfig: null, }; } @@ -3672,6 +4022,8 @@ function createEmptyReviewSheet(): NonIntegratedReviewSheet { productImageName: "", size: "", material: "", + barcode: "", + reviewResult: "", color: "", issues: [createEmptyReviewIssue()], }; @@ -3688,6 +4040,8 @@ function cloneReviewSheet( ...sheet, version: sheet.version || "v0.0.1", reviewMethod: sheet.reviewMethod || "现场", + barcode: sheet.barcode ?? "", + reviewResult: sheet.reviewResult ?? "", issues: sheet.issues.length ? sheet.issues.map((issue) => cloneReviewIssue(issue)) : [createEmptyReviewIssue()], @@ -3749,6 +4103,20 @@ function buildReviewIssueImageId() { return `review-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function getImageFilesFromClipboardData(clipboardData: DataTransfer) { + const filesFromClipboard = Array.from(clipboardData.files ?? []).filter( + (file) => file.type.startsWith("image/"), + ); + if (filesFromClipboard.length > 0) { + return filesFromClipboard; + } + + return Array.from(clipboardData.items ?? []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); +} + function cloneReviewIssue( issue: NonIntegratedReviewIssue, ): NonIntegratedReviewIssue { @@ -5038,6 +5406,7 @@ function createProductFactoryRowFromSku( supplierCode, warehouse: existingRow?.warehouse?.trim() || "", minimumOrderQuantity: existingRow?.minimumOrderQuantity?.trim() || "", + taxIncludedUnitPrice: existingRow?.taxIncludedUnitPrice?.trim() || "", leadTime: existingRow?.leadTime?.trim() || "", assemblyMode: existingRow?.assemblyMode?.trim() || "", productionType: existingRow?.productionType?.trim() || "", @@ -5073,7 +5442,7 @@ function buildProductFactoryRows( } }); - return skuItems.map((sku) => { + const rows = skuItems.map((sku) => { const exactRow = rowBySkuId.get(sku.id); const sharedRow = sku.packageGroupId.trim() ? sharedRowByPackageGroupKey.get(getSkuPackageGroupKey(sku)) @@ -5085,13 +5454,56 @@ function buildProductFactoryRows( supplierCodeByName, ); }); + + return normalizeModelSharedProductFactoryRows(rows); +} + +function normalizeModelSharedProductFactoryRows( + rows: NonIntegratedFactoryRow[], +) { + const rowsByModelKey = new Map(); + + rows.forEach((row) => { + const modelKey = getSkuFactoryModelKey(row); + if (!modelKey) return; + + const modelRows = rowsByModelKey.get(modelKey) ?? []; + modelRows.push(row); + rowsByModelKey.set(modelKey, modelRows); + }); + + const sharedValueByModelAndKey = new Map(); + rowsByModelKey.forEach((modelRows, modelKey) => { + MODEL_SHARED_PRODUCT_FACTORY_KEYS.forEach((key) => { + const sharedValue = + modelRows.find((row) => row[key].trim())?.[key].trim() ?? ""; + sharedValueByModelAndKey.set(`${modelKey}:${key}`, sharedValue); + }); + }); + + return rows.map((row) => { + const modelKey = getSkuFactoryModelKey(row); + if (!modelKey) return row; + + let nextRow = row; + MODEL_SHARED_PRODUCT_FACTORY_KEYS.forEach((key) => { + const sharedValue = sharedValueByModelAndKey.get(`${modelKey}:${key}`); + if (sharedValue === undefined || nextRow[key] === sharedValue) return; + nextRow = { + ...nextRow, + [key]: sharedValue, + }; + }); + + return nextRow; + }); } function normalizeFactoryRowsForPayload( rows: NonIntegratedFactoryRow[], supplierCodeByName: ReadonlyMap = SUPPLIER_CODE_BY_NAME, ): NonIntegratedFactoryRow[] { - return rows.map((row) => { + const normalizedRows = rows.map((row) => { const supplier = normalizeFactorySupplier( row.supplierName, supplierCodeByName, @@ -5117,6 +5529,7 @@ function normalizeFactoryRowsForPayload( supplierCode, warehouse: row.warehouse.trim(), minimumOrderQuantity: row.minimumOrderQuantity.trim(), + taxIncludedUnitPrice: row.taxIncludedUnitPrice.trim(), leadTime: row.leadTime.trim(), assemblyMode: row.assemblyMode.trim(), productionType: row.productionType.trim(), @@ -5126,6 +5539,8 @@ function normalizeFactoryRowsForPayload( executeStandard: row.executeStandard.trim(), }; }); + + return normalizeModelSharedProductFactoryRows(normalizedRows); } function isProductFactoryRowComplete(row: NonIntegratedFactoryRow) { @@ -5655,6 +6070,48 @@ function isFinishedSampleShipmentRowComplete( ); } +function normalizeReviewBarcodeForMatch(value: string | null | undefined) { + return (value ?? "").trim().toUpperCase(); +} + +function hasReviewSheetDecisionContent(sheet: NonIntegratedReviewSheet) { + return [sheet.barcode, sheet.reviewResult].some((value) => value.trim()); +} + +function hasReviewRoundContent(round: NonIntegratedReviewRound) { + return ( + hasReviewSheetDecisionContent(round.reviewSheet) || + round.reviewSheet.issues.some((issue) => hasReviewIssueContent(issue)) + ); +} + +function buildNoModificationFinishedSampleShipmentRows( + skuItems: NonIntegratedSkuItem[], + reviewBarcode: string, +) { + const normalizedReviewBarcode = normalizeReviewBarcodeForMatch(reviewBarcode); + const hasMatchedSku = + !normalizedReviewBarcode || + skuItems.some( + (sku) => + normalizeReviewBarcodeForMatch(sku.barcode) === normalizedReviewBarcode, + ); + if (!hasMatchedSku) { + return null; + } + + return skuItems.map((sku) => ({ + skuId: sku.id, + sampleQuantity: + normalizedReviewBarcode && + normalizeReviewBarcodeForMatch(sku.barcode) === normalizedReviewBarcode + ? "1" + : "0", + shipped: FINISHED_SAMPLE_SHIPMENT_AUTO_SHIPPED, + trackingNumber: FINISHED_SAMPLE_SHIPMENT_EXISTING_SAMPLE_TRACKING, + })); +} + type ProductFirstQualityDisplayRow = NonIntegratedFirstQualityRow & { image: string; model: string; @@ -5763,9 +6220,21 @@ function createFormValuesFromProduct( ...normalizedOpeningReference.adaptableBrands, ...(product.adaptableBrands ?? []), ]); + const formalSpu = getNonIntegratedFormalSpu(product); + const prefixedFormalSpu = formalSpu + ? applyBrandPrefixToNonIntegratedCode(formalSpu, normalizedBrand) + : ""; + const productCode = product.productCode?.trim() ?? ""; + const normalizedProductCode = + prefixedFormalSpu && + (!productCode || + stripNonIntegratedBrandPrefix(productCode) === + stripNonIntegratedBrandPrefix(prefixedFormalSpu)) + ? prefixedFormalSpu + : productCode; return { - spu: getNonIntegratedFormalSpu(product), + spu: prefixedFormalSpu, year: product.year || getAutoProductYear(product.createdAt), demandFlowNo: product.demandFlowNo ?? "", processTemplate: product.processTemplate ?? "", @@ -5812,7 +6281,7 @@ function createFormValuesFromProduct( purchaser: product.purchaser ?? "", previewImage: sharedProductImage, previewImageName: sharedProductImageName, - productCode: product.productCode ?? "", + productCode: normalizedProductCode, size: normalizedOpeningReference.size || product.size || "", color: product.color ?? "", installMethod: product.installMethod ?? "", @@ -6972,7 +7441,7 @@ function WorkspacePreviewEmptyState({ return (

{title} @@ -9159,47 +9628,6 @@ function BomSteelMaterialField({ ); } -function collectSkuBatchKeys( - skuItems: NonIntegratedSkuItem[], - skuBatchCount: number, -) { - const normalizedBatchCount = Math.max( - 1, - Number.isFinite(skuBatchCount) ? Math.floor(skuBatchCount) : 1, - ); - const batchKeys = new Set(); - - for (let batchNo = 1; batchNo <= normalizedBatchCount; batchNo += 1) { - batchKeys.add(String(batchNo)); - } - - skuItems.forEach((item) => { - const batchNo = Number.isFinite(item.batchNo) - ? Math.max(1, Math.floor(item.batchNo)) - : 1; - batchKeys.add(String(batchNo)); - }); - - return batchKeys; -} - -function ensureSkuBatchConfirmedAt( - skuItems: NonIntegratedSkuItem[], - skuBatchCount: number, - currentConfirmedAt: Record, - confirmedAt: string, -) { - const nextConfirmedAt = { ...currentConfirmedAt }; - - collectSkuBatchKeys(skuItems, skuBatchCount).forEach((batchKey) => { - if (!nextConfirmedAt[batchKey]?.trim()) { - nextConfirmedAt[batchKey] = confirmedAt; - } - }); - - return nextConfirmedAt; -} - function buildProductPayload( id: string, formValues: FormValues, @@ -9244,6 +9672,8 @@ function buildProductPayload( item.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: item.model3dNodeMaterialIds ?? {}, + model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig), }; return productDevelopmentKind === "series" @@ -9262,6 +9692,8 @@ function buildProductPayload( model3dDataUrl: detail.model3dDataUrl.trim(), model3dNodeOffsets: detail.model3dNodeOffsets, model3dNodeRotations: detail.model3dNodeRotations, + model3dNodeMaterialIds: detail.model3dNodeMaterialIds, + model3dUvConfig: normalizeAssetVariantUvConfig(detail.model3dUvConfig), outerPackagingType: detail.outerPackagingType.trim(), innerPackagingItems: detail.innerPackagingItems .map((item) => ({ @@ -9277,6 +9709,10 @@ function buildProductPayload( packagingModel3dDataUrl: detail.packagingModel3dDataUrl.trim(), packagingModel3dNodeOffsets: detail.packagingModel3dNodeOffsets, packagingModel3dNodeRotations: detail.packagingModel3dNodeRotations, + packagingModel3dNodeMaterialIds: detail.packagingModel3dNodeMaterialIds, + packagingModel3dUvConfig: normalizeAssetVariantUvConfig( + detail.packagingModel3dUvConfig, + ), })); const firstStyleDetail = normalizedStyleDetails[0]; const bomDetailsForPayload = migrateBomDetailsToBarcodeKeys( @@ -9401,6 +9837,12 @@ function buildProductPayload( productImageName: round.reviewSheet.productImageName.trim(), size: round.reviewSheet.size.trim(), material: round.reviewSheet.material.trim(), + barcode: round.reviewSheet.barcode?.trim() ?? "", + reviewResult: REVIEW_RESULT_OPTIONS.includes( + round.reviewSheet.reviewResult as (typeof REVIEW_RESULT_OPTIONS)[number], + ) + ? round.reviewSheet.reviewResult + : "", color: round.reviewSheet.color.trim(), issues: round.reviewSheet.issues .map((issue) => { @@ -9459,18 +9901,6 @@ function buildProductPayload( productImage: sharedProductImage, productImageName: sharedProductImageName, }; - const isStockProductPayload = - formValues.processTemplate.trim() === STOCK_PRODUCT_LABEL || - normalizedOpeningReference.currentStage.trim() === STOCK_PRODUCT_LABEL; - const normalizedSkuBatchConfirmedAt = isStockProductPayload - ? ensureSkuBatchConfirmedAt( - skuItems, - skuBatchCount, - skuBatchConfirmedAt, - now, - ) - : skuBatchConfirmedAt; - return { id, productDevelopmentKind, @@ -9571,7 +10001,7 @@ function buildProductPayload( skuBatchCount, skuBatchFlowNos, skuBatchChangeTypes, - skuBatchConfirmedAt: normalizedSkuBatchConfirmedAt, + skuBatchConfirmedAt, factoryRows: normalizeFactoryRowsForPayload(factoryRows), pricingRows: normalizePricingRowsForPayload(pricingRows), planningLayoutRows: normalizePlanningLayoutRowsForPayload( @@ -9633,6 +10063,7 @@ export default function NonIntegratedDetailClient({ seriesNameOptions = [], existingSeriesNames = [], seriesDraftSkuOptions = [], + materialLibraryFinishes = [], }: { mode: "create" | "edit" | "view"; productId?: string; @@ -9664,6 +10095,7 @@ export default function NonIntegratedDetailClient({ seriesNameOptions?: string[]; existingSeriesNames?: string[]; seriesDraftSkuOptions?: SeriesDraftSkuOption[]; + materialLibraryFinishes?: MaterialDTO[]; }) { const router = useRouter(); const previewFileInputRef = useRef(null); @@ -10476,6 +10908,9 @@ export default function NonIntegratedDetailClient({ const [selectedPreviewNodeName, setSelectedPreviewNodeName] = useState< string | null >(null); + const [selectedPreviewNodeNames, setSelectedPreviewNodeNames] = useState< + string[] + >([]); const [previewGizmoMode, setPreviewGizmoMode] = useState< "translate" | "rotate" >("translate"); @@ -10489,12 +10924,42 @@ export default function NonIntegratedDetailClient({ useState([]); const previewNodeOffsetMapRef = useRef({}); const previewNodeRotationMapRef = useRef({}); + const selectedPreviewNodeNamesRef = useRef([]); const previewTransformBaselineRef = useRef(null); const previewTransformNodeNameRef = useRef(null); + const [previewContextMenuState, setPreviewContextMenuState] = useState<{ + visible: boolean; + x: number; + y: number; + nodeName: string | null; + targetNodeNames: string[]; + }>({ + visible: false, + x: 0, + y: 0, + nodeName: null, + targetNodeNames: [], + }); + const [activePreviewSubmenu, setActivePreviewSubmenu] = useState< + "preview-material" | "preview-uv" | null + >(null); + const [previewUvEditorForm, setPreviewUvEditorForm] = + useState(() => + createPreviewUvEditorFormState(null), + ); + const previewUvEditorFormRef = useRef(previewUvEditorForm); const [isExportingReviewSheet, setIsExportingReviewSheet] = useState(false); const [isExportingQualityInspection, setIsExportingQualityInspection] = useState(false); + const [ + downloadingProductFactoryContractType, + setDownloadingProductFactoryContractType, + ] = useState(null); + const [ + productFactoryContractMenuOpen, + setProductFactoryContractMenuOpen, + ] = useState(false); const autoSaveInitializedRef = useRef(Boolean(initialBomDraftSnapshot)); const bomDraftWriteInitializedRef = useRef(Boolean(initialBomDraftSnapshot)); const previousBomDraftWriteStateRef = useRef<{ @@ -10616,6 +11081,23 @@ export default function NonIntegratedDetailClient({ currentUserRole === "ADMIN" || currentUserRole === "SUPER_ADMIN"; const isCurrentUserFullDetailEditor = isIntakeNonIntegratedFullEditor(currentPermissionUser); + const isCurrentUserPurchaseDownloadRestricted = + hasCurrentUserContext && + !isCurrentUserFullDetailEditor && + isIntakeNonIntegratedSupplyChainUser(currentPermissionUser); + const canPurchaseDownloadActiveWorkspace = + isIntakeNonIntegratedSupplyChainUser(currentPermissionUser) && + (activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY || + activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY); + const canDownloadWorkspaceFiles = + !isCurrentUserPurchaseDownloadRestricted || + canPurchaseDownloadActiveWorkspace; + const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback( + (category: DocumentWorkspaceCategory) => + !isCurrentUserPurchaseDownloadRestricted || + !isPurchaseHiddenDocumentWorkspaceCategory(category), + [isCurrentUserPurchaseDownloadRestricted], + ); const currentUserNameNormalized = currentUserName?.trim() ?? ""; const currentUserIdNormalized = currentUserId?.trim() ?? ""; const linkEntryProductPatentDashboardUser = @@ -10665,6 +11147,31 @@ export default function NonIntegratedDetailClient({ )); 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 = @@ -10831,7 +11338,7 @@ export default function NonIntegratedDetailClient({ existingProduct?.processTemplate?.trim() === STOCK_PRODUCT_LABEL || normalizedExistingOpeningReference.currentStage.trim() === STOCK_PRODUCT_LABEL; - const isSkuContentReadOnly = isReadOnly || isStockProductRecord; + const isSkuContentReadOnly = isReadOnly; const activeSkuBatchNo = activeSkuBatch === "all" ? null : activeSkuBatch; const formalSpuValue = formValues.spu.trim(); const hasFormalSpu = formalSpuValue.length > 0; @@ -10849,17 +11356,27 @@ export default function NonIntegratedDetailClient({ currentProductId: productId, }); }, [hasFormalSpu, productId, spuEstimateRefreshToken, spuRuleProductCategory]); + const effectiveSpuBrand = + normalizeNonIntegratedBrand(formValues.brand) || + normalizeNonIntegratedBrand(openingReference.brand) || + NON_INTEGRATED_DEFAULT_BRAND; + const estimatedFormalSpu = estimatedSpuAssignment + ? applyBrandPrefixToNonIntegratedCode( + estimatedSpuAssignment.spu, + effectiveSpuBrand, + ) + : ""; const basicInfoSpuDisplayValue = hasFormalSpu ? formalSpuValue : estimatedSpuAssignment - ? `预计SPU:${estimatedSpuAssignment.spu}` + ? `预计SPU:${estimatedFormalSpu}` : spuRuleProductCategory ? "未配置SPU规则" : "选择类目后自动预估"; const seriesCreateEstimatedSpuLabel = hasFormalSpu ? formalSpuValue : estimatedSpuAssignment - ? `预计SPU:${estimatedSpuAssignment.spu}` + ? `预计SPU:${estimatedFormalSpu}` : seriesCreateProductCategory ? "当前类目未配置SPU规则" : "选择类目后自动预估"; @@ -10872,7 +11389,7 @@ export default function NonIntegratedDetailClient({ estimatedSpuAssignment, ); const isSpuCategoryLocked = hasFormalSpu && !isStockProductRecord; - const skuCodePreviewSpu = formalSpuValue || estimatedSpuAssignment?.spu || ""; + const skuCodePreviewSpu = formalSpuValue || estimatedFormalSpu; const derivedSkuItems = useMemo( () => skuItems.map((item, index) => @@ -11093,6 +11610,10 @@ export default function NonIntegratedDetailClient({ () => new Map(derivedSkuItems.map((item) => [item.id, item.batchNo || 1])), [derivedSkuItems], ); + const derivedSkuItemById = useMemo( + () => new Map(derivedSkuItems.map((item) => [item.id, item])), + [derivedSkuItems], + ); const factoryConfirmedSkuIdSet = useMemo( () => new Set( @@ -11122,6 +11643,12 @@ export default function NonIntegratedDetailClient({ activeSkuBatchNo !== null && isSkuBatchFactoryConfirmed(activeSkuBatchNo); const canEditActiveSkuBatch = !isSkuContentReadOnly && !isActiveSkuBatchFactoryConfirmed; + const canShowSkuUploadActions = !isSkuContentReadOnly; + const isSkuWorkbookUploadDisabled = + isImportingSku || + activeSkuBatchNo === null || + isActiveSkuBatchFactoryConfirmed; + const isSkuImageUploadDisabled = isActiveSkuBatchFactoryConfirmed; const displayedProductFactoryRows = useMemo(() => { if (activeDownstreamSkuBatch === "all") return productFactoryRows; return productFactoryRows.filter( @@ -11293,9 +11820,15 @@ export default function NonIntegratedDetailClient({ const effectiveSelectedReviewRoundId = selectedReviewRound?.id ?? ""; const reviewSheet = selectedReviewRound?.reviewSheet ?? createEmptyReviewSheet(); + const isReviewNodeLocked = + documentWorkspaceConfirmations[REVIEW_WORKSPACE_CATEGORY] === true && + Boolean(documentWorkspaceApprovedAt[REVIEW_WORKSPACE_CATEGORY]); + const canEditReviewSheet = !isReadOnly; + const canEditReviewResult = + !isReadOnly && (!isReviewNodeLocked || !reviewSheet.reviewResult.trim()); const canDeleteSelectedReviewRound = isCurrentUserAdmin && - !isReadOnly && + canEditReviewSheet && reviewRounds.length > 1 && Boolean(effectiveSelectedReviewRoundId); const reviewFactoryOptions = useMemo( @@ -11315,6 +11848,14 @@ export default function NonIntegratedDetailClient({ supplierNameOptions, ], ); + const reviewBarcodeOptions = useMemo( + () => + getUniqueNonEmptyStrings([ + reviewSheet.barcode, + ...derivedSkuItems.map((item) => item.barcode), + ]), + [derivedSkuItems, reviewSheet.barcode], + ); const selectedStyleKey = isComponentSupplementMode ? effectiveSelectedSupplementComponentCode ? `component:${effectiveSelectedSupplementComponentCode}` @@ -11342,6 +11883,18 @@ export default function NonIntegratedDetailClient({ ? (styleDetails[selectedStyleKey]?.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP) : EMPTY_PREVIEW_NODE_ROTATION_MAP; + const selectedStyleModelNodeMaterialIds = selectedStyleKey + ? (styleDetails[selectedStyleKey]?.model3dNodeMaterialIds ?? {}) + : {}; + const selectedStyleModelUvConfig = useMemo( + () => + selectedStyleKey + ? normalizeAssetVariantUvConfig( + styleDetails[selectedStyleKey]?.model3dUvConfig, + ) + : null, + [selectedStyleKey, styleDetails], + ); const hasSkuOverrideModel = Boolean( selectedSupplementSourceSku?.model3dDataUrl?.trim(), ); @@ -11359,6 +11912,15 @@ export default function NonIntegratedDetailClient({ ? (selectedSupplementSourceSku?.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP) : selectedStyleModelNodeRotations; + const effectiveModel3dNodeMaterialIds = + selectedSupplementSourceSku?.model3dNodeMaterialIds ?? + selectedStyleModelNodeMaterialIds; + const effectiveModel3dUvConfig = useMemo( + () => + normalizeAssetVariantUvConfig(selectedSupplementSourceSku?.model3dUvConfig) ?? + selectedStyleModelUvConfig, + [selectedStyleModelUvConfig, selectedSupplementSourceSku?.model3dUvConfig], + ); const previewNodeOffsets = useMemo( () => Object.entries(previewNodeOffsetMap).map(([nodeName, offset]) => ({ @@ -11375,12 +11937,152 @@ export default function NonIntegratedDetailClient({ })), [previewNodeRotationMap], ); + const activeSelectedPreviewNodeNames = useMemo(() => { + if (selectedPreviewNodeNames.length > 0) { + return selectedPreviewNodeNames; + } + return selectedPreviewNodeName ? [selectedPreviewNodeName] : []; + }, [selectedPreviewNodeName, selectedPreviewNodeNames]); + const activeSelectedPreviewNodeCount = activeSelectedPreviewNodeNames.length; + const activePreviewMaterialTargetNodeNames = useMemo(() => { + if ( + previewContextMenuState.visible && + previewContextMenuState.targetNodeNames.length > 0 + ) { + return previewContextMenuState.targetNodeNames; + } + return activeSelectedPreviewNodeNames; + }, [ + activeSelectedPreviewNodeNames, + previewContextMenuState.targetNodeNames, + previewContextMenuState.visible, + ]); + const activePreviewMaterialTargetNodeCount = + activePreviewMaterialTargetNodeNames.length; + const activePreviewUvNodeName = + previewContextMenuState.nodeName ?? selectedPreviewNodeName; + const activePreviewUvNodeLabel = activePreviewUvNodeName ?? "默认全部节点"; + const materialFinishById = useMemo( + () => + new Map( + materialLibraryFinishes.map((finish) => [finish.id, finish] as const), + ), + [materialLibraryFinishes], + ); + const autoPreviewMaterialFinish = useMemo( + () => + findBestMaterialFinish( + materialLibraryFinishes, + selectedSupplementColor, + selectedSupplementMaterial, + ), + [materialLibraryFinishes, selectedSupplementColor, selectedSupplementMaterial], + ); + const autoPreviewMaterialConfig = useMemo( + () => toMaterialConfig(autoPreviewMaterialFinish), + [autoPreviewMaterialFinish], + ); + const previewMaterialAssignments = useMemo< + Array<{ nodeName: string; material: MaterialConfig }> + >(() => { + if (previewNodeNames.length === 0) return []; + + return previewNodeNames + .map((nodeName) => { + const overrideFinishId = effectiveModel3dNodeMaterialIds[nodeName]; + const overrideMaterialConfig = overrideFinishId + ? toMaterialConfig(materialFinishById.get(overrideFinishId)) + : null; + const material = overrideMaterialConfig ?? autoPreviewMaterialConfig; + const materialWithUv = material + ? (applyAssetVariantUvConfigToMaterial( + material, + effectiveModel3dUvConfig, + { nodeName }, + ) ?? material) + : null; + return materialWithUv ? { nodeName, material: materialWithUv } : null; + }) + .filter( + ( + entry, + ): entry is { + nodeName: string; + material: MaterialConfig; + } => Boolean(entry), + ); + }, [ + autoPreviewMaterialConfig, + effectiveModel3dNodeMaterialIds, + effectiveModel3dUvConfig, + materialFinishById, + previewNodeNames, + ]); + const selectedPreviewNodeMaterialId = (() => { + if (activePreviewMaterialTargetNodeNames.length === 0) return ""; + const firstMaterialId = + effectiveModel3dNodeMaterialIds[ + activePreviewMaterialTargetNodeNames[0]! + ] ?? ""; + const hasSameMaterial = activePreviewMaterialTargetNodeNames.every( + (nodeName) => + (effectiveModel3dNodeMaterialIds[nodeName] ?? "") === firstMaterialId, + ); + return hasSameMaterial ? firstMaterialId : ""; + })(); + const selectedPreviewNodeMaterial = + selectedPreviewNodeMaterialId.length > 0 + ? materialFinishById.get(selectedPreviewNodeMaterialId) ?? null + : null; + const [previewMaterialApplyTarget, setPreviewMaterialApplyTarget] = useState< + "all" | "node" + >("all"); + const previewMaterialQuickFinishes = useMemo(() => { + const seen = new Set(); + return [ + autoPreviewMaterialFinish, + selectedPreviewNodeMaterial, + ...materialLibraryFinishes, + ] + .filter((finish): finish is MaterialDTO => { + if (!finish || seen.has(finish.id)) return false; + seen.add(finish.id); + return true; + }) + .slice(0, 8); + }, [ + autoPreviewMaterialFinish, + materialLibraryFinishes, + selectedPreviewNodeMaterial, + ]); + useEffect(() => { + if (activeSelectedPreviewNodeCount > 0) { + setPreviewMaterialApplyTarget("node"); + return; + } + setPreviewMaterialApplyTarget("all"); + }, [activeSelectedPreviewNodeCount]); + useEffect(() => { + selectedPreviewNodeNamesRef.current = selectedPreviewNodeNames; + }, [selectedPreviewNodeNames]); useEffect(() => { previewNodeOffsetMapRef.current = previewNodeOffsetMap; }, [previewNodeOffsetMap]); useEffect(() => { previewNodeRotationMapRef.current = previewNodeRotationMap; }, [previewNodeRotationMap]); + useEffect(() => { + previewUvEditorFormRef.current = previewUvEditorForm; + }, [previewUvEditorForm]); + useEffect(() => { + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(effectiveModel3dUvConfig, { + nodeName: activePreviewUvNodeName, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, [activePreviewUvNodeName, effectiveModel3dUvConfig]); const selectedBomStyleKey = selectedStyleKey; const selectedBomSkuKey = !isComponentSupplementMode && selectedBomStyleKey && selectedSupplementSku @@ -11556,9 +12258,7 @@ export default function NonIntegratedDetailClient({ if (reviewRounds.length === 0) return 0; if (reviewRounds.length > 1) return reviewRounds.length; - return reviewRounds[0].reviewSheet.issues.some((issue) => - hasReviewIssueContent(issue), - ) + return hasReviewRoundContent(reviewRounds[0]) ? 1 : 0; }, @@ -11600,8 +12300,18 @@ export default function NonIntegratedDetailClient({ [uploadFiles], ); const visibleDocumentWorkspaceCategories = useMemo( - () => UPLOAD_CATEGORY_PANEL_ORDER, - [], + () => + UPLOAD_CATEGORY_PANEL_ORDER.filter( + isDocumentWorkspaceCategoryVisibleForCurrentUser, + ), + [isDocumentWorkspaceCategoryVisibleForCurrentUser], + ); + const basicInfoInsertIndex = useMemo( + () => + visibleDocumentWorkspaceCategories.filter((category) => + BASIC_INFO_PRECEDING_WORKSPACE_CATEGORIES.includes(category), + ).length, + [visibleDocumentWorkspaceCategories], ); const visibleGroupedUploadFiles = useMemo( () => @@ -11679,6 +12389,17 @@ export default function NonIntegratedDetailClient({ const displayedProductFactoryRowsComplete = displayedProductFactoryRows.length > 0 && displayedProductFactoryRows.every(isProductFactoryRowComplete); + function isProductPricingRowSystemEntered( + row: Pick, + ) { + return ( + PRODUCT_PRICING_JST_PUSH_ENABLED && + (row.systemEnteredAt ?? "").trim().length > 0 + ); + } + const allProductPricingRowsComplete = + productPricingRows.length > 0 && + productPricingRows.every(isProductPricingRowComplete); const displayedSampleMeasurementRowsComplete = displayedProductSampleMeasurementRows.length > 0 && displayedProductSampleMeasurementRows.every( @@ -11692,6 +12413,18 @@ export default function NonIntegratedDetailClient({ const displayedProductPricingRowsComplete = displayedProductPricingRows.length > 0 && displayedProductPricingRows.every(isProductPricingRowComplete); + const allProductPricingRowsSystemEntered = + productPricingRows.length > 0 && + productPricingRows.every(isProductPricingRowSystemEntered); + const productPricingCanBeConfirmed = + allProductPricingRowsComplete && allProductPricingRowsSystemEntered; + const displayedProductPricingRowsReadyForPush = + displayedProductPricingRows.length > 0 && + displayedProductPricingRows.some( + (row) => + isProductPricingRowComplete(row) && + !isProductPricingRowSystemEntered(row), + ); const displayedProductLimitRowsComplete = displayedProductLimitRows.length > 0 && displayedProductLimitRows.every(isProductLimitRowComplete); @@ -11863,9 +12596,13 @@ export default function NonIntegratedDetailClient({ (file) => resolveDetectionWorkspaceAssetType(file) === "quality-standard", ), }); - signatures[NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY] = JSON.stringify({ - skuItems, - }); + signatures[NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY] = + buildSkuContentRevisionSignature({ + skuItems, + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, + }); return signatures; }, [ @@ -11884,6 +12621,9 @@ export default function NonIntegratedDetailClient({ pricingRows, reviewRounds, sampleMeasurementRows, + skuBatchChangeTypes, + skuBatchCount, + skuBatchFlowNos, skuItems, styleDetails, uploadFiles, @@ -11901,12 +12641,22 @@ export default function NonIntegratedDetailClient({ const isWorkflowNodeConfirmed = ( category: WorkflowNodeCategory, hasContent: boolean, - ) => - !shouldRequireWorkflowConfirmation(category) || - (hasContent && + ) => { + if ( + category === PRODUCT_PRICING_WORKSPACE_CATEGORY && + !productPricingCanBeConfirmed + ) { + return false; + } + + return ( + !shouldRequireWorkflowConfirmation(category) || + (hasContent && documentWorkspaceConfirmations[category] === true && Boolean(documentWorkspaceApprovedAt[category]) && - !dirtyWorkflowNodeCategories.has(category)); + !dirtyWorkflowNodeCategories.has(category)) + ); + }; const isDocumentWorkspaceNodeLocked = (category: DocumentWorkspaceCategory) => documentWorkspaceConfirmations[category] === true && Boolean(documentWorkspaceApprovedAt[category]); @@ -11918,7 +12668,7 @@ export default function NonIntegratedDetailClient({ ); const isProductPricingNodeLocked = isDocumentWorkspaceNodeLocked( PRODUCT_PRICING_WORKSPACE_CATEGORY, - ); + ) && productPricingCanBeConfirmed; const displayedSampleMeasurementIsConfirmed = isWorkflowNodeConfirmed( SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, displayedSampleMeasurementRowsComplete, @@ -11932,7 +12682,7 @@ export default function NonIntegratedDetailClient({ ) || isFinishedSampleShipmentNodeLocked; const displayedProductPricingIsConfirmed = isWorkflowNodeConfirmed( PRODUCT_PRICING_WORKSPACE_CATEGORY, - displayedProductPricingRowsComplete, + productPricingCanBeConfirmed, ) || isProductPricingNodeLocked; const displayedProductLimitIsConfirmed = isWorkflowNodeConfirmed( PRODUCT_LIMIT_WORKSPACE_CATEGORY, @@ -11949,6 +12699,35 @@ export default function NonIntegratedDetailClient({ const basicInfoDisplayCount = basicInfoIsApproved ? 1 : 0; const basicInfoNeedsConfirmation = !basicInfoIsApproved; const isWorkflowCompleted = false; + const productPricingStoredConfirmation = + documentWorkspaceConfirmations[PRODUCT_PRICING_WORKSPACE_CATEGORY] === true; + const productPricingStoredApprovedAt = Boolean( + documentWorkspaceApprovedAt[PRODUCT_PRICING_WORKSPACE_CATEGORY], + ); + useEffect(() => { + if (productPricingCanBeConfirmed) return; + if (!productPricingStoredConfirmation && !productPricingStoredApprovedAt) { + return; + } + + setDocumentWorkspaceConfirmations((current) => { + if (!current[PRODUCT_PRICING_WORKSPACE_CATEGORY]) return current; + return { + ...current, + [PRODUCT_PRICING_WORKSPACE_CATEGORY]: false, + }; + }); + setDocumentWorkspaceApprovedAt((current) => { + if (!current[PRODUCT_PRICING_WORKSPACE_CATEGORY]) return current; + const next = { ...current }; + delete next[PRODUCT_PRICING_WORKSPACE_CATEGORY]; + return next; + }); + }, [ + productPricingCanBeConfirmed, + productPricingStoredApprovedAt, + productPricingStoredConfirmation, + ]); useEffect(() => { if (isWorkflowCompleted) return; @@ -12117,6 +12896,10 @@ export default function NonIntegratedDetailClient({ currentPermissionProduct, currentPermissionUser, )); + const canDownloadProductFactoryContract = + !hasCurrentUserContext || + isCurrentUserAdmin || + isIntakeNonIntegratedSupplyChainUser(currentPermissionUser); const skuStyleKeys = useMemo(() => { const nextKeys = new Set(); @@ -12582,6 +13365,151 @@ export default function NonIntegratedDetailClient({ selectedPackagingStyleDetail.packagingModel3dDataUrl.trim(); const selectedPackagingModelFileName = selectedPackagingStyleDetail.packagingModel3dFileName.trim(); + const selectedPackagingModelNodeOffsets = + selectedPackagingStyleDetail.packagingModel3dNodeOffsets ?? + EMPTY_PREVIEW_NODE_OFFSET_MAP; + const selectedPackagingModelNodeRotations = + selectedPackagingStyleDetail.packagingModel3dNodeRotations ?? + EMPTY_PREVIEW_NODE_ROTATION_MAP; + const selectedPackagingModelNodeMaterialIds = + selectedPackagingStyleDetail.packagingModel3dNodeMaterialIds ?? {}; + const selectedPackagingModelUvConfig = useMemo( + () => + normalizeAssetVariantUvConfig( + selectedPackagingStyleDetail.packagingModel3dUvConfig, + ), + [selectedPackagingStyleDetail.packagingModel3dUvConfig], + ); + const selectedPackagingSourceSku = useMemo( + () => + selectedPackagingStyleEntry + ? (derivedSkuItems.find( + (item) => + getStyleKeyFromModel(item.model) === selectedPackagingStyleEntry.key, + ) ?? null) + : null, + [derivedSkuItems, selectedPackagingStyleEntry], + ); + const autoPackagingMaterialFinish = useMemo( + () => + findBestMaterialFinish( + materialLibraryFinishes, + selectedPackagingSourceSku?.color ?? "", + selectedPackagingSourceSku?.material ?? "", + ), + [ + materialLibraryFinishes, + selectedPackagingSourceSku?.color, + selectedPackagingSourceSku?.material, + ], + ); + const autoPackagingMaterialConfig = useMemo( + () => toMaterialConfig(autoPackagingMaterialFinish), + [autoPackagingMaterialFinish], + ); + const packagingPreviewMaterialAssignments = useMemo< + Array<{ nodeName: string; material: MaterialConfig }> + >(() => { + if (previewNodeNames.length === 0) return []; + + return previewNodeNames + .map((nodeName) => { + const overrideFinishId = selectedPackagingModelNodeMaterialIds[nodeName]; + const overrideMaterialConfig = overrideFinishId + ? toMaterialConfig(materialFinishById.get(overrideFinishId)) + : null; + const material = overrideMaterialConfig ?? autoPackagingMaterialConfig; + const materialWithUv = material + ? (applyAssetVariantUvConfigToMaterial( + material, + selectedPackagingModelUvConfig, + { nodeName }, + ) ?? material) + : null; + return materialWithUv ? { nodeName, material: materialWithUv } : null; + }) + .filter( + ( + entry, + ): entry is { + nodeName: string; + material: MaterialConfig; + } => Boolean(entry), + ); + }, [ + autoPackagingMaterialConfig, + materialFinishById, + previewNodeNames, + selectedPackagingModelNodeMaterialIds, + selectedPackagingModelUvConfig, + ]); + const selectedPackagingPreviewNodeMaterialId = (() => { + if (activePreviewMaterialTargetNodeNames.length === 0) return ""; + const firstMaterialId = + selectedPackagingModelNodeMaterialIds[ + activePreviewMaterialTargetNodeNames[0]! + ] ?? ""; + const hasSameMaterial = activePreviewMaterialTargetNodeNames.every( + (nodeName) => + (selectedPackagingModelNodeMaterialIds[nodeName] ?? "") === + firstMaterialId, + ); + return hasSameMaterial ? firstMaterialId : ""; + })(); + const selectedPackagingPreviewNodeMaterial = + selectedPackagingPreviewNodeMaterialId.length > 0 + ? materialFinishById.get(selectedPackagingPreviewNodeMaterialId) ?? null + : null; + const packagingPreviewMaterialQuickFinishes = useMemo(() => { + const seen = new Set(); + return [ + autoPackagingMaterialFinish, + selectedPackagingPreviewNodeMaterial, + ...materialLibraryFinishes, + ] + .filter((finish): finish is MaterialDTO => { + if (!finish || seen.has(finish.id)) return false; + seen.add(finish.id); + return true; + }) + .slice(0, 8); + }, [ + autoPackagingMaterialFinish, + materialLibraryFinishes, + selectedPackagingPreviewNodeMaterial, + ]); + const isPackagingModelPreviewActive = + isPackagingWorkspace && Boolean(selectedPackagingStyleEntry); + const activeModel3dNodeMaterialIds = isPackagingModelPreviewActive + ? selectedPackagingModelNodeMaterialIds + : effectiveModel3dNodeMaterialIds; + const activeModel3dUvConfig = isPackagingModelPreviewActive + ? selectedPackagingModelUvConfig + : effectiveModel3dUvConfig; + const activeAutoPreviewMaterialFinish = isPackagingModelPreviewActive + ? autoPackagingMaterialFinish + : autoPreviewMaterialFinish; + const activePreviewMaterialQuickFinishes = isPackagingModelPreviewActive + ? packagingPreviewMaterialQuickFinishes + : previewMaterialQuickFinishes; + const activeSelectedPreviewNodeMaterialId = isPackagingModelPreviewActive + ? selectedPackagingPreviewNodeMaterialId + : selectedPreviewNodeMaterialId; + useEffect(() => { + if (!isPackagingModelPreviewActive) return; + + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(selectedPackagingModelUvConfig, { + nodeName: activePreviewUvNodeName, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, [ + activePreviewUvNodeName, + isPackagingModelPreviewActive, + selectedPackagingModelUvConfig, + ]); const activeInstallationAssetFiles = useMemo( () => activeInstallationStyleFiles.filter( @@ -12717,7 +13645,9 @@ export default function NonIntegratedDetailClient({ ? "按批次显示 SKU 明细,并在每条 SKU 后维护首批质检批次、结论、详细说明和附件。" : isPackagingWorkspace ? "按款式维护包装方案、外包装配置和包装 3D 资料。" - : `${activeDocumentWorkspaceCopy.description} 支持直接预览图片、PDF、Excel 和 PPT,其他文件可在这里集中下载查看。`; + : canDownloadWorkspaceFiles + ? `${activeDocumentWorkspaceCopy.description} 支持直接预览图片、PDF、Excel 和 PPT,其他文件可在这里集中下载查看。` + : `${activeDocumentWorkspaceCopy.description} 支持直接预览图片、PDF、Excel 和 PPT。`; const activeProductPhotoPanel = selectedProductPhotoTarget.group; const activeProductPhotoPanelTitle = getProductPhotoPanelTitle( activeProductPhotoPanel, @@ -13114,6 +14044,7 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations?: NonIntegratedDocumentWorkspaceConfirmationMap; documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap; reviewRounds?: NonIntegratedReviewRound[]; + finishedSampleShipmentRows?: NonIntegratedFinishedSampleShipmentRow[]; openingReference?: NonIntegratedOpeningReference; skuItems?: NonIntegratedSkuItem[]; skuBatchCount?: number; @@ -13128,6 +14059,8 @@ export default function NonIntegratedDetailClient({ const nextDocumentWorkspaceApprovedAt = overrides?.documentWorkspaceApprovedAt ?? documentWorkspaceApprovedAt; const nextReviewRounds = overrides?.reviewRounds ?? reviewRounds; + const nextFinishedSampleShipmentRows = + overrides?.finishedSampleShipmentRows ?? productFinishedSampleShipmentRows; const nextOpeningReference = overrides?.openingReference ?? openingReferenceRef.current; const nextSkuItems = overrides?.skuItems ?? skuItems; @@ -13166,7 +14099,7 @@ export default function NonIntegratedDetailClient({ planningLayoutRows, productLimitRows, productSampleMeasurementRows, - productFinishedSampleShipmentRows, + nextFinishedSampleShipmentRows, productFirstQualityRows, uploadFiles, documentWorkspaceNotes, @@ -13350,6 +14283,26 @@ export default function NonIntegratedDetailClient({ mode, ]); + useEffect(() => { + if (mode === "create") return; + if (documentWorkspaceView !== "document") return; + if (!isCurrentUserPurchaseDownloadRestricted) return; + if (!isPurchaseHiddenDocumentWorkspaceCategory(activeDocumentWorkspaceCategory)) { + return; + } + + setActiveDocumentWorkspaceCategory( + visibleDocumentWorkspaceCategories[0] ?? + OPENING_REFERENCE_WORKSPACE_CATEGORY, + ); + }, [ + activeDocumentWorkspaceCategory, + documentWorkspaceView, + isCurrentUserPurchaseDownloadRestricted, + mode, + visibleDocumentWorkspaceCategories, + ]); + useEffect(() => { if (!isInstallationWorkspace) return; @@ -13755,10 +14708,16 @@ export default function NonIntegratedDetailClient({ ]); useEffect(() => { + if (isPackagingWorkspace) { + return; + } + if (!effectiveModel3dDataUrl) { setModel3dPreviewUrl(""); setPreviewNodeNames([]); setSelectedPreviewNodeName(null); + setSelectedPreviewNodeNames([]); + selectedPreviewNodeNamesRef.current = []; setPreviewNodeOffsetMap(EMPTY_PREVIEW_NODE_OFFSET_MAP); setPreviewNodeRotationMap(EMPTY_PREVIEW_NODE_ROTATION_MAP); previewNodeOffsetMapRef.current = EMPTY_PREVIEW_NODE_OFFSET_MAP; @@ -13779,6 +14738,8 @@ export default function NonIntegratedDetailClient({ setModel3dPreviewUrl(attachedUrl); setPreviewNodeNames([]); setSelectedPreviewNodeName(null); + setSelectedPreviewNodeNames([]); + selectedPreviewNodeNamesRef.current = []; setPreviewNodeOffsetMap(effectiveModel3dNodeOffsets); setPreviewNodeRotationMap(effectiveModel3dNodeRotations); previewNodeOffsetMapRef.current = effectiveModel3dNodeOffsets; @@ -13798,10 +14759,67 @@ export default function NonIntegratedDetailClient({ effectiveModel3dFileName, effectiveSelectedSupplementSkuId, hasSkuOverrideModel, + isPackagingWorkspace, productId, selectedStyleKey, ]); + useEffect(() => { + if (!isPackagingWorkspace) { + return; + } + + if (!selectedPackagingModelDataUrl) { + setModel3dPreviewUrl(""); + setPreviewNodeNames([]); + setSelectedPreviewNodeName(null); + setSelectedPreviewNodeNames([]); + selectedPreviewNodeNamesRef.current = []; + setPreviewNodeOffsetMap(EMPTY_PREVIEW_NODE_OFFSET_MAP); + setPreviewNodeRotationMap(EMPTY_PREVIEW_NODE_ROTATION_MAP); + previewNodeOffsetMapRef.current = EMPTY_PREVIEW_NODE_OFFSET_MAP; + previewNodeRotationMapRef.current = EMPTY_PREVIEW_NODE_ROTATION_MAP; + setPreviewNodeTransformUndoStack([]); + setPreviewNodeTransformRedoStack([]); + previewTransformBaselineRef.current = null; + previewTransformNodeNameRef.current = null; + return; + } + + const previewUrl = + dataUrlToObjectUrl(selectedPackagingModelDataUrl) ?? + selectedPackagingModelDataUrl; + const attachedUrl = attachModelFileNameToUrl( + previewUrl, + selectedPackagingModelFileName, + ); + setModel3dPreviewUrl(attachedUrl); + setPreviewNodeNames([]); + setSelectedPreviewNodeName(null); + setSelectedPreviewNodeNames([]); + selectedPreviewNodeNamesRef.current = []; + setPreviewNodeOffsetMap(selectedPackagingModelNodeOffsets); + setPreviewNodeRotationMap(selectedPackagingModelNodeRotations); + previewNodeOffsetMapRef.current = selectedPackagingModelNodeOffsets; + previewNodeRotationMapRef.current = selectedPackagingModelNodeRotations; + setPreviewNodeTransformUndoStack([]); + setPreviewNodeTransformRedoStack([]); + previewTransformBaselineRef.current = null; + previewTransformNodeNameRef.current = null; + + return () => { + if (previewUrl.startsWith("blob:")) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [ + isPackagingWorkspace, + productId, + selectedPackagingModelDataUrl, + selectedPackagingModelFileName, + selectedPackagingStyleEntry?.key, + ]); + const applyPreviewNodeTransformState = useCallback( (state: PreviewNodeTransformState) => { const nextOffsets = clonePreviewNodeOffsetMap(state.offsets); @@ -13811,6 +14829,19 @@ export default function NonIntegratedDetailClient({ setPreviewNodeOffsetMap(nextOffsets); setPreviewNodeRotationMap(nextRotations); + if (isPackagingModelPreviewActive && selectedPackagingStyleEntry) { + const styleKey = selectedPackagingStyleEntry.key; + setStyleDetails((current) => ({ + ...current, + [styleKey]: { + ...(current[styleKey] ?? EMPTY_STYLE_DETAIL), + packagingModel3dNodeOffsets: nextOffsets, + packagingModel3dNodeRotations: nextRotations, + }, + })); + return; + } + if (hasSkuOverrideModel && selectedSupplementSourceSku) { setSkuItems((current) => current.map((item) => @@ -13837,7 +14868,13 @@ export default function NonIntegratedDetailClient({ })); } }, - [hasSkuOverrideModel, selectedStyleKey, selectedSupplementSourceSku], + [ + hasSkuOverrideModel, + isPackagingModelPreviewActive, + selectedPackagingStyleEntry, + selectedStyleKey, + selectedSupplementSourceSku, + ], ); const handlePreviewTransformStart = useCallback((nodeName: string) => { @@ -13873,12 +14910,13 @@ export default function NonIntegratedDetailClient({ return; } + applyPreviewNodeTransformState(current); setPreviewNodeTransformUndoStack((previous) => [ ...previous, clonePreviewNodeTransformState(baseline), ]); setPreviewNodeTransformRedoStack([]); - }, []); + }, [applyPreviewNodeTransformState]); const handlePreviewUndo = useCallback(() => { setPreviewNodeTransformUndoStack((previous) => { @@ -13960,6 +14998,123 @@ export default function NonIntegratedDetailClient({ }; }, [handlePreviewRedo, handlePreviewUndo, isReadOnly]); + const commitPreviewNodeSelection = useCallback((nextNodeNames: string[]) => { + const seen = new Set(); + const normalizedNodeNames = nextNodeNames + .map((nodeName) => nodeName.trim()) + .filter((nodeName) => { + if (!nodeName || seen.has(nodeName)) { + return false; + } + seen.add(nodeName); + return true; + }); + selectedPreviewNodeNamesRef.current = normalizedNodeNames; + setSelectedPreviewNodeNames(normalizedNodeNames); + setSelectedPreviewNodeName( + normalizedNodeNames[normalizedNodeNames.length - 1] ?? null, + ); + }, []); + + const handlePreviewNodeSelect = useCallback( + (payload: { nodeName: string | null; appendToSelection?: boolean }) => { + const syncOpenMaterialMenuTarget = (targetNodeNames: string[]) => { + setPreviewContextMenuState((current) => + current.visible + ? { + ...current, + nodeName: + targetNodeNames[targetNodeNames.length - 1] ?? + current.nodeName, + targetNodeNames, + } + : current, + ); + }; + const nodeName = payload.nodeName?.trim() ?? ""; + if (!nodeName) { + if (!payload.appendToSelection) { + commitPreviewNodeSelection([]); + syncOpenMaterialMenuTarget([]); + } + return; + } + + if (!payload.appendToSelection) { + const nextNodeNames = [nodeName]; + commitPreviewNodeSelection(nextNodeNames); + syncOpenMaterialMenuTarget(nextNodeNames); + return; + } + + const currentNodeNames = selectedPreviewNodeNamesRef.current; + const nextNodeNames = currentNodeNames.includes(nodeName) + ? currentNodeNames.filter((current) => current !== nodeName) + : [...currentNodeNames, nodeName]; + commitPreviewNodeSelection(nextNodeNames); + syncOpenMaterialMenuTarget(nextNodeNames); + }, + [commitPreviewNodeSelection], + ); + + const handlePreviewNodeContextMenu = useCallback( + (payload: { + nodeName: string | null; + rawNodeName: string | null; + clientX: number; + clientY: number; + appendToSelection?: boolean; + }) => { + const nodeName = + payload.nodeName?.trim() || + payload.rawNodeName?.trim() || + selectedPreviewNodeName; + const currentNodeNames = selectedPreviewNodeNamesRef.current; + let targetNodeNames = currentNodeNames; + + if (nodeName) { + if (payload.appendToSelection) { + if (!currentNodeNames.includes(nodeName)) { + targetNodeNames = [...currentNodeNames, nodeName]; + commitPreviewNodeSelection(targetNodeNames); + } else { + targetNodeNames = currentNodeNames; + setSelectedPreviewNodeName(nodeName); + } + } else if (currentNodeNames.length > 1) { + targetNodeNames = currentNodeNames; + if (currentNodeNames.includes(nodeName)) { + setSelectedPreviewNodeName(nodeName); + } + } else if (!currentNodeNames.includes(nodeName)) { + targetNodeNames = [nodeName]; + commitPreviewNodeSelection(targetNodeNames); + } else { + targetNodeNames = currentNodeNames; + setSelectedPreviewNodeName(nodeName); + } + } else if (!payload.appendToSelection) { + targetNodeNames = []; + commitPreviewNodeSelection([]); + } + + setPreviewContextMenuState({ + visible: true, + x: payload.clientX, + y: payload.clientY, + nodeName, + targetNodeNames: + targetNodeNames.length > 0 + ? targetNodeNames + : nodeName + ? [nodeName] + : [], + }); + setActivePreviewSubmenu("preview-material"); + }, + [commitPreviewNodeSelection, selectedPreviewNodeName], + ); + const handlePreviewNodePositionOffsetChange = useCallback( (payload: { nodeName: string; offset: [number, number, number] }) => { const currentOffsets = previewNodeOffsetMapRef.current; @@ -13978,39 +15133,17 @@ export default function NonIntegratedDetailClient({ [payload.nodeName]: payload.offset, }; previewNodeOffsetMapRef.current = nextOffsets; - setPreviewNodeOffsetMap(nextOffsets); - if (hasSkuOverrideModel && selectedSupplementSourceSku) { - setSkuItems((current) => - current.map((item) => - item.id === selectedSupplementSourceSku.id - ? { - ...item, - model3dNodeOffsets: { - ...(item.model3dNodeOffsets ?? {}), - [payload.nodeName]: payload.offset, - }, - } - : item, - ), - ); + if (previewTransformBaselineRef.current) { return; } - if (selectedStyleKey) { - setStyleDetails((current) => ({ - ...current, - [selectedStyleKey]: { - ...(current[selectedStyleKey] ?? EMPTY_STYLE_DETAIL), - model3dNodeOffsets: { - ...(current[selectedStyleKey]?.model3dNodeOffsets ?? {}), - [payload.nodeName]: payload.offset, - }, - }, - })); - } + applyPreviewNodeTransformState({ + offsets: nextOffsets, + rotations: previewNodeRotationMapRef.current, + }); }, - [hasSkuOverrideModel, selectedStyleKey, selectedSupplementSourceSku], + [applyPreviewNodeTransformState], ); const handlePreviewNodeRotationChange = useCallback( @@ -14035,18 +15168,40 @@ export default function NonIntegratedDetailClient({ [payload.nodeName]: payload.rotation, }; previewNodeRotationMapRef.current = nextRotations; - setPreviewNodeRotationMap(nextRotations); - if (hasSkuOverrideModel && selectedSupplementSourceSku) { + if (previewTransformBaselineRef.current) { + return; + } + + applyPreviewNodeTransformState({ + offsets: previewNodeOffsetMapRef.current, + rotations: nextRotations, + }); + }, + [applyPreviewNodeTransformState], + ); + + const persistPreviewNodeMaterialIds = useCallback( + (nextMaterialIds: PreviewNodeMaterialIdMap) => { + if (isPackagingModelPreviewActive && selectedPackagingStyleEntry) { + const styleKey = selectedPackagingStyleEntry.key; + setStyleDetails((current) => ({ + ...current, + [styleKey]: { + ...(current[styleKey] ?? EMPTY_STYLE_DETAIL), + packagingModel3dNodeMaterialIds: nextMaterialIds, + }, + })); + return; + } + + if (selectedSupplementSourceSku) { setSkuItems((current) => current.map((item) => item.id === selectedSupplementSourceSku.id ? { ...item, - model3dNodeRotations: { - ...(item.model3dNodeRotations ?? {}), - [payload.nodeName]: payload.rotation, - }, + model3dNodeMaterialIds: nextMaterialIds, } : item, ), @@ -14059,17 +15214,408 @@ export default function NonIntegratedDetailClient({ ...current, [selectedStyleKey]: { ...(current[selectedStyleKey] ?? EMPTY_STYLE_DETAIL), - model3dNodeRotations: { - ...(current[selectedStyleKey]?.model3dNodeRotations ?? {}), - [payload.nodeName]: payload.rotation, - }, + model3dNodeMaterialIds: nextMaterialIds, }, })); } }, - [hasSkuOverrideModel, selectedStyleKey, selectedSupplementSourceSku], + [ + isPackagingModelPreviewActive, + selectedPackagingStyleEntry, + selectedStyleKey, + selectedSupplementSourceSku, + ], ); + const handlePreviewNodeMaterialChange = useCallback( + (nodeName: string, materialId: string) => { + if (isReadOnly) return; + if (!nodeName) return; + + const nextMaterialIds = { ...activeModel3dNodeMaterialIds }; + if (materialId) { + nextMaterialIds[nodeName] = materialId; + } else { + delete nextMaterialIds[nodeName]; + } + persistPreviewNodeMaterialIds(nextMaterialIds); + }, + [activeModel3dNodeMaterialIds, isReadOnly, persistPreviewNodeMaterialIds], + ); + + const handlePreviewNodesMaterialChange = useCallback( + (nodeNames: string[], materialId: string) => { + if (isReadOnly) return; + const targetNodeNames = Array.from( + new Set(nodeNames.map((nodeName) => nodeName.trim()).filter(Boolean)), + ); + if (targetNodeNames.length === 0) return; + + const nextMaterialIds = { ...activeModel3dNodeMaterialIds }; + targetNodeNames.forEach((nodeName) => { + if (materialId) { + nextMaterialIds[nodeName] = materialId; + } else { + delete nextMaterialIds[nodeName]; + } + }); + persistPreviewNodeMaterialIds(nextMaterialIds); + }, + [activeModel3dNodeMaterialIds, isReadOnly, persistPreviewNodeMaterialIds], + ); + + const handlePreviewMaterialApply = useCallback( + (materialId: string) => { + if (isReadOnly) return; + + if ( + previewMaterialApplyTarget === "node" && + activePreviewMaterialTargetNodeNames.length > 0 + ) { + handlePreviewNodesMaterialChange( + activePreviewMaterialTargetNodeNames, + materialId, + ); + return; + } + + if (previewNodeNames.length === 0) return; + + const nextMaterialIds = { ...activeModel3dNodeMaterialIds }; + previewNodeNames.forEach((nodeName) => { + if (materialId) { + nextMaterialIds[nodeName] = materialId; + } else { + delete nextMaterialIds[nodeName]; + } + }); + persistPreviewNodeMaterialIds(nextMaterialIds); + }, + [ + activeModel3dNodeMaterialIds, + activePreviewMaterialTargetNodeNames, + handlePreviewNodesMaterialChange, + isReadOnly, + persistPreviewNodeMaterialIds, + previewMaterialApplyTarget, + previewNodeNames, + ], + ); + + const closePreviewContextMenu = useCallback(() => { + setPreviewContextMenuState((current) => ({ ...current, visible: false })); + setActivePreviewSubmenu(null); + }, []); + + const persistPreviewUvConfig = useCallback( + (nextUvConfig: AssetVariantUvConfig | null) => { + const normalizedConfig = normalizeAssetVariantUvConfig(nextUvConfig); + + if (isPackagingModelPreviewActive && selectedPackagingStyleEntry) { + const styleKey = selectedPackagingStyleEntry.key; + setStyleDetails((current) => ({ + ...current, + [styleKey]: { + ...(current[styleKey] ?? EMPTY_STYLE_DETAIL), + packagingModel3dUvConfig: normalizedConfig, + }, + })); + return; + } + + if (selectedSupplementSourceSku) { + setSkuItems((current) => + current.map((item) => + item.id === selectedSupplementSourceSku.id + ? { + ...item, + model3dUvConfig: normalizedConfig, + } + : item, + ), + ); + return; + } + + if (selectedStyleKey) { + setStyleDetails((current) => ({ + ...current, + [selectedStyleKey]: { + ...(current[selectedStyleKey] ?? EMPTY_STYLE_DETAIL), + model3dUvConfig: normalizedConfig, + }, + })); + } + }, + [ + isPackagingModelPreviewActive, + selectedPackagingStyleEntry, + selectedStyleKey, + selectedSupplementSourceSku, + ], + ); + + const handlePatchPreviewUvEditorForm = useCallback( + (patch: Partial) => { + if (!canEditPreviewModelMaterial) return; + + const nextForm = { + ...previewUvEditorFormRef.current, + ...patch, + }; + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + + const projectionAxis = nextForm.textureProjectionAxis || null; + const nextConfig = updateAssetVariantUvConfig( + activeModel3dUvConfig, + { nodeName: activePreviewUvNodeName, projectionAxis }, + () => parsePreviewUvEditorFormState(nextForm), + ); + persistPreviewUvConfig(nextConfig); + }, + [ + activePreviewUvNodeName, + activeModel3dUvConfig, + canEditPreviewModelMaterial, + persistPreviewUvConfig, + ], + ); + + const handleSelectPreviewUvProjectionAxis = useCallback( + (projectionAxis: "" | AssetVariantUvProjectionAxis) => { + if (!canEditPreviewModelMaterial) return; + + const nextConfig = updateAssetVariantUvConfig( + activeModel3dUvConfig, + { nodeName: activePreviewUvNodeName }, + (currentBaseConfig) => { + const nextBaseConfig = { ...(currentBaseConfig ?? {}) }; + + if (projectionAxis) { + nextBaseConfig.textureProjectionAxis = projectionAxis; + } else { + delete nextBaseConfig.textureProjectionAxis; + } + + return Object.keys(nextBaseConfig).length > 0 + ? nextBaseConfig + : null; + }, + ); + persistPreviewUvConfig(nextConfig); + + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(nextConfig, { + nodeName: activePreviewUvNodeName, + projectionAxis: projectionAxis || null, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, + [ + activePreviewUvNodeName, + activeModel3dUvConfig, + canEditPreviewModelMaterial, + persistPreviewUvConfig, + ], + ); + + const handleResetPreviewUvEditor = useCallback(() => { + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(activeModel3dUvConfig, { + nodeName: activePreviewUvNodeName, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, [activePreviewUvNodeName, activeModel3dUvConfig]); + + const handleClearPreviewUvEditor = useCallback(() => { + if (!canEditPreviewModelMaterial) return; + + const projectionAxis = previewUvEditorFormRef.current.textureProjectionAxis; + const nextForm = createPreviewUvEditorFormState( + projectionAxis ? { textureProjectionAxis: projectionAxis } : null, + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + + const nextConfig = updateAssetVariantUvConfig( + activeModel3dUvConfig, + { + nodeName: activePreviewUvNodeName, + projectionAxis: projectionAxis || null, + }, + () => null, + ); + persistPreviewUvConfig(nextConfig); + }, [ + activePreviewUvNodeName, + activeModel3dUvConfig, + canEditPreviewModelMaterial, + persistPreviewUvConfig, + ]); + + const handleKeepPreviewUvDraft = useCallback(() => { + if (!canEditPreviewModelMaterial) return; + setSaveMessage("渲染参数已暂存"); + setSaveMessageType("success"); + closePreviewContextMenu(); + }, [canEditPreviewModelMaterial, closePreviewContextMenu]); + + const previewContextMenuPosition = useMemo( + () => calculateMenuPosition(previewContextMenuState.x, previewContextMenuState.y, 180, 92), + [previewContextMenuState.x, previewContextMenuState.y], + ); + + const previewUvSettingsPanelPosition = useMemo(() => { + const panelWidth = 264; + const panelHeight = 326; + const viewportGap = 12; + const menuGap = 12; + const rightSideLeft = 180 + menuGap; + const viewportHeight = + typeof window !== "undefined" ? window.innerHeight : 900; + const viewportWidth = + typeof window !== "undefined" ? window.innerWidth : 1440; + const minTop = viewportGap - previewContextMenuPosition.top; + const maxTop = + viewportHeight - previewContextMenuPosition.top - panelHeight - viewportGap; + const top = Math.max(minTop, Math.min(0, maxTop)); + const availableHeight = + viewportHeight - previewContextMenuPosition.top - top - viewportGap; + const maxHeight = Math.max(220, Math.min(panelHeight, availableHeight)); + + if ( + previewContextMenuPosition.left + rightSideLeft + panelWidth > + viewportWidth - viewportGap + ) { + return { + left: -panelWidth - menuGap, + top, + maxHeight, + }; + } + + return { + left: rightSideLeft, + top, + maxHeight, + }; + }, [previewContextMenuPosition.left, previewContextMenuPosition.top]); + + const previewMaterialSettingsPanelPosition = useMemo(() => { + const panelWidth = 180; + const panelHeight = 390; + const viewportGap = 12; + const menuGap = 12; + const rightSideLeft = 180 + menuGap; + const viewportHeight = + typeof window !== "undefined" ? window.innerHeight : 900; + const viewportWidth = + typeof window !== "undefined" ? window.innerWidth : 1440; + const minTop = viewportGap - previewContextMenuPosition.top; + const maxTop = + viewportHeight - previewContextMenuPosition.top - panelHeight - viewportGap; + const top = Math.max(minTop, Math.min(0, maxTop)); + const availableHeight = + viewportHeight - previewContextMenuPosition.top - top - viewportGap; + const maxHeight = Math.max(260, Math.min(panelHeight, availableHeight)); + + if ( + previewContextMenuPosition.left + rightSideLeft + panelWidth > + viewportWidth - viewportGap + ) { + return { + left: -panelWidth - menuGap, + top, + maxHeight, + }; + } + + return { + left: rightSideLeft, + top, + maxHeight, + }; + }, [previewContextMenuPosition.left, previewContextMenuPosition.top]); + + const previewContextMenuGroups = useMemo( + () => + canEditPreviewModelMaterial && model3dPreviewUrl + ? [ + { + id: "preview-render", + items: [ + { + id: "preview-material-settings", + label: "花色", + submenu: "preview-material", + }, + { + id: "preview-uv-settings", + label: "渲染参数", + submenu: "preview-uv", + }, + ], + }, + ] + : [], + [canEditPreviewModelMaterial, model3dPreviewUrl], + ); + + const handlePreviewContextMenuItemClick = useCallback( + (item: ContextMenuItem) => { + if (item.id === "preview-material-settings") { + setActivePreviewSubmenu("preview-material"); + return; + } + if (item.id === "preview-uv-settings") { + setActivePreviewSubmenu("preview-uv"); + } + }, + [], + ); + + const handlePreviewContextMenuHover = useCallback((item: ContextMenuItem) => { + setActivePreviewSubmenu( + item.submenu === "preview-material" || item.submenu === "preview-uv" + ? item.submenu + : null, + ); + }, []); + + useEffect(() => { + if (!previewContextMenuState.visible) return; + + const handleClose = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (target?.closest('[data-context-menu-root="true"]')) return; + closePreviewContextMenu(); + }; + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePreviewContextMenu(); + } + }; + + document.addEventListener("mousedown", handleClose); + document.addEventListener("keydown", handleEsc); + + return () => { + document.removeEventListener("mousedown", handleClose); + document.removeEventListener("keydown", handleEsc); + }; + }, [closePreviewContextMenu, previewContextMenuState.visible]); + + useEffect(() => { + if (canEditPreviewModelMaterial && model3dPreviewUrl) return; + closePreviewContextMenu(); + }, [canEditPreviewModelMaterial, closePreviewContextMenu, model3dPreviewUrl]); + function setFormValuesAndRef(updater: (current: FormValues) => FormValues) { setFormValues((current) => { const nextValues = updater(current); @@ -14135,10 +15681,19 @@ export default function NonIntegratedDetailClient({ return ""; } + const nextBrand = + normalizeNonIntegratedBrand(formValuesRef.current.brand) || + normalizeNonIntegratedBrand(openingReferenceRef.current.brand) || + NON_INTEGRATED_DEFAULT_BRAND; + const formalSpu = applyBrandPrefixToNonIntegratedCode( + assignment.spu, + nextBrand, + ); + setFormValuesAndRef((current) => ({ ...current, - spu: assignment.spu, - productCode: current.productCode || assignment.spu, + spu: formalSpu, + productCode: current.productCode || formalSpu, salesCategory: current.salesCategory || salesCategory, productCategory: current.productCategory || productCategory, })); @@ -14148,10 +15703,10 @@ export default function NonIntegratedDetailClient({ productCategory: current.productCategory || productCategory, })); setSpuEstimateRefreshToken((current) => current + 1); - setSaveMessage(`SPU已生效:${assignment.spu}`); + setSaveMessage(`SPU已生效:${formalSpu}`); setSaveMessageType("success"); - return assignment.spu; + return formalSpu; } function updateField(key: keyof FormValues, value: string) { @@ -14179,11 +15734,18 @@ export default function NonIntegratedDetailClient({ brand: nextBrand, adaptableBrands: nextAdaptableBrands, spu: current.spu - ? current.spu - : applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand), + ? applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand) + : current.spu, })); syncBasicInfoFieldToOpeningReference(key, nextBrand, nextAdaptableBrands); setSkuItems((current) => syncSkuItemsBrandPrefix(current, nextBrand)); + if (isStockProductCreate) { + setStockProductSpu((current) => + current.trim() + ? applyBrandPrefixToNonIntegratedCode(current, nextBrand) + : current, + ); + } if (!isReadOnly) { setSaveMessage(""); setSaveMessageType("success"); @@ -14199,6 +15761,22 @@ export default function NonIntegratedDetailClient({ scheduleAutoSave(); } + function handleStockProductSpuChange(value: string) { + const normalizedValue = value.toUpperCase(); + if (!normalizedValue.trim()) { + setStockProductSpu(normalizedValue); + return; + } + + const nextBrand = + normalizeNonIntegratedBrand(formValuesRef.current.brand) || + normalizeNonIntegratedBrand(openingReferenceRef.current.brand) || + NON_INTEGRATED_DEFAULT_BRAND; + setStockProductSpu( + applyBrandPrefixToNonIntegratedCode(normalizedValue, nextBrand), + ); + } + function syncBasicInfoFieldToOpeningReference( key: keyof FormValues, value: string, @@ -14279,7 +15857,6 @@ export default function NonIntegratedDetailClient({ const targetItem = current.find((item) => item.id === id); if (!targetItem) return current; if ( - isStockProductRecord || isSkuItemFactoryConfirmed(targetItem) || isImportedSkuContentLocked(targetItem) ) { @@ -14380,11 +15957,7 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item, index) => { if (item.id !== id) return item; - if ( - isStockProductRecord || - isSkuItemFactoryConfirmed(item) || - isImportedSkuContentLocked(item) - ) + if (isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item)) return item; return applySkuSupplierName(item, index, value); }), @@ -14400,11 +15973,7 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item, index) => { if (item.id !== id) return item; - if ( - isStockProductRecord || - isSkuItemFactoryConfirmed(item) || - isImportedSkuContentLocked(item) - ) + if (isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item)) return item; return applySkuSupplierName(item, index, item.manufacturer.trim()); @@ -14421,11 +15990,7 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item) => { if (item.id !== id) return item; - if ( - isStockProductRecord || - isSkuItemFactoryConfirmed(item) || - isImportedSkuContentLocked(item) - ) + if (isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item)) return item; return { ...item, executeStandard: value }; }), @@ -14441,11 +16006,7 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item) => { if (item.id !== id) return item; - if ( - isStockProductRecord || - isSkuItemFactoryConfirmed(item) || - isImportedSkuContentLocked(item) - ) + if (isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item)) return item; const normalizedValue = item.executeStandard.trim(); @@ -14495,6 +16056,17 @@ export default function NonIntegratedDetailClient({ if (!targetItem) return new Set([targetId]); if (MODEL_SHARED_PRODUCT_FACTORY_KEYS.has(key)) { + const targetModelKey = getSkuFactoryModelKey(targetItem); + if (!targetModelKey) return new Set([targetId]); + + return new Set( + items + .filter((item) => getSkuFactoryModelKey(item) === targetModelKey) + .map((item) => item.id), + ); + } + + if (MODEL_AUTOFILL_PRODUCT_FACTORY_KEYS.has(key)) { const targetModelKey = getSkuFactoryModelKey(targetItem); if (!targetModelKey || !value.trim()) return new Set([targetId]); const savedRowBySkuId = new Map( @@ -14550,7 +16122,7 @@ export default function NonIntegratedDetailClient({ key, ); return current.map((item, index) => - targetSkuIds.has(item.id) && !isStockProductRecord + targetSkuIds.has(item.id) ? applySkuSupplierName(item, index, supplierName) : item, ); @@ -14565,17 +16137,13 @@ export default function NonIntegratedDetailClient({ key, ); return current.map((item) => - targetSkuIds.has(item.id) && !isStockProductRecord - ? { ...item, setCount: value } - : item, + targetSkuIds.has(item.id) ? { ...item, setCount: value } : item, ); }); } if (key === "installMethod") { setSkuItems((current) => { - if (isStockProductRecord) return current; - const targetItem = current.find((item) => item.id === skuId); if (!targetItem) return current; @@ -14642,7 +16210,7 @@ export default function NonIntegratedDetailClient({ }; }); - if (MODEL_SHARED_PRODUCT_FACTORY_KEYS.has(key)) { + if (MODEL_AUTOFILL_PRODUCT_FACTORY_KEYS.has(key)) { const normalizedValue = value.trim(); productFactoryModelAutofillRef.current.delete( getProductFactoryModelAutofillKey(skuId, key), @@ -14696,6 +16264,21 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + function handleBatchConfirmProductPricingPush() { + if (isReadOnly || isProductPricingNodeLocked) return; + if (displayedProductPricingRows.length === 0) return; + if (!displayedProductPricingRowsReadyForPush) { + setSaveMessage("请先填写当前批次要推送 SKU 的成本价"); + setSaveMessageType("error"); + return; + } + if (!PRODUCT_PRICING_JST_PUSH_ENABLED) { + setSaveMessage("聚水潭接口未接入,暂不可批量确认推送"); + setSaveMessageType("error"); + return; + } + } + function updateProductLimitRow( skuId: string, key: Exclude, @@ -15663,7 +17246,6 @@ export default function NonIntegratedDetailClient({ const targetItem = current[targetIndex]; if ( - isStockProductRecord || isSkuItemFactoryConfirmed(targetItem) || (!options.allowImportedContentLocked && isImportedSkuContentLocked(targetItem)) @@ -15758,8 +17340,7 @@ export default function NonIntegratedDetailClient({ skuItems.some( (item) => item.id === targetId && - (isStockProductRecord || - isSkuItemFactoryConfirmed(item) || + (isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item)), ) ) { @@ -15838,7 +17419,6 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item, index) => { if ( - isStockProductRecord || isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item) ) { @@ -15907,7 +17487,6 @@ export default function NonIntegratedDetailClient({ if ( isReadOnly || - isStockProductRecord || isSkuItemFactoryConfirmed(item) || isImportedSkuContentLocked(item) ) { @@ -15923,6 +17502,15 @@ export default function NonIntegratedDetailClient({ function handleOpenDocumentWorkspace(category: DocumentWorkspaceCategory) { const nextCategory = category; + if (!isDocumentWorkspaceCategoryVisibleForCurrentUser(nextCategory)) { + setActiveDocumentWorkspaceCategory( + visibleDocumentWorkspaceCategories[0] ?? + OPENING_REFERENCE_WORKSPACE_CATEGORY, + ); + setDocumentWorkspaceView("document"); + return; + } + setActiveDocumentWorkspaceCategory(category); setDocumentWorkspaceView("document"); @@ -16151,14 +17739,21 @@ export default function NonIntegratedDetailClient({ ...current, brand: nextBrand, spu: current.spu - ? current.spu - : applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand), + ? applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand) + : current.spu, adaptableBrands: normalizeNonIntegratedBrandList([ nextBrand, ...current.adaptableBrands, ]).filter((brand) => BRAND_OPTIONS.includes(brand)), })); setSkuItems((current) => syncSkuItemsBrandPrefix(current, nextBrand)); + if (isStockProductCreate) { + setStockProductSpu((current) => + current.trim() + ? applyBrandPrefixToNonIntegratedCode(current, nextBrand) + : current, + ); + } return; } @@ -16339,10 +17934,50 @@ export default function NonIntegratedDetailClient({ })(), ) : undefined; + const confirmedReviewSheet = + nextReviewRounds?.find((round) => round.id === effectiveSelectedReviewRoundId) + ?.reviewSheet ?? null; + let nextFinishedSampleShipmentRows: + | NonIntegratedFinishedSampleShipmentRow[] + | undefined; + + if (category === REVIEW_WORKSPACE_CATEGORY) { + const confirmedReviewBarcode = confirmedReviewSheet?.barcode.trim() ?? ""; + const confirmedReviewResult = confirmedReviewSheet?.reviewResult.trim() ?? ""; + const isValidReviewResult = REVIEW_RESULT_OPTIONS.includes( + confirmedReviewResult as (typeof REVIEW_RESULT_OPTIONS)[number], + ); + + if (!isValidReviewResult) { + setSaveMessage("请先选择审样结果,再确认评审资料。"); + setSaveMessageType("error"); + return; + } + + if (confirmedReviewResult === REVIEW_RESULT_NO_MODIFICATION) { + const autoShipmentRows = buildNoModificationFinishedSampleShipmentRows( + derivedSkuItems, + confirmedReviewBarcode, + ); + if (!autoShipmentRows) { + setSaveMessage("当前审样条码没有匹配到 SKU 条码,暂不能自动完成成品寄样。"); + setSaveMessageType("error"); + return; + } + + nextFinishedSampleShipmentRows = autoShipmentRows; + nextConfirmations[FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY] = true; + nextApprovedAt[FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY] = approvedAt; + } + } + setDocumentWorkspaceConfirmations(nextConfirmations); setDocumentWorkspaceApprovedAt(nextApprovedAt); if (nextReviewRounds) { setReviewRounds(nextReviewRounds); + if (nextFinishedSampleShipmentRows) { + setFinishedSampleShipmentRows(nextFinishedSampleShipmentRows); + } if (documentWorkspaceRevisionSignatureRef.current) { documentWorkspaceRevisionSignatureRef.current = { ...documentWorkspaceRevisionSignatureRef.current, @@ -16353,6 +17988,13 @@ export default function NonIntegratedDetailClient({ REVIEW_WORKSPACE_CATEGORY, ), }), + ...(nextFinishedSampleShipmentRows + ? { + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: JSON.stringify( + nextFinishedSampleShipmentRows, + ), + } + : {}), }; } } @@ -16364,6 +18006,9 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations: nextConfirmations, documentWorkspaceApprovedAt: nextApprovedAt, ...(nextReviewRounds ? { reviewRounds: nextReviewRounds } : {}), + ...(nextFinishedSampleShipmentRows + ? { finishedSampleShipmentRows: nextFinishedSampleShipmentRows } + : {}), }, }); } @@ -16412,11 +18057,16 @@ export default function NonIntegratedDetailClient({ async function handleConfirmProductPricingRows() { if (isReadOnly || displayedProductPricingRows.length === 0) return; - if (!displayedProductPricingRowsComplete) { + if (!allProductPricingRowsComplete) { setSaveMessage("请先填完整产品核价数据"); setSaveMessageType("error"); return; } + if (!allProductPricingRowsSystemEntered) { + setSaveMessage("请先将全部 SKU 确认推送并录入系统"); + setSaveMessageType("error"); + return; + } if (displayedProductPricingIsConfirmed) return; await handleConfirmDocumentWorkspaceCategory( @@ -17210,6 +18860,8 @@ export default function NonIntegratedDetailClient({ packagingModel3dDataUrl: uploadedAsset.url, packagingModel3dNodeOffsets: {}, packagingModel3dNodeRotations: {}, + packagingModel3dNodeMaterialIds: {}, + packagingModel3dUvConfig: null, })); setSaveMessage( `${selectedPackagingStyleEntry.label}的包装 3D 模型已更新。`, @@ -17511,6 +19163,10 @@ export default function NonIntegratedDetailClient({ } async function handleDownloadUploadFile(file: NonIntegratedUploadFile) { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } if (!file.dataUrl) return; try { @@ -17555,6 +19211,134 @@ export default function NonIntegratedDetailClient({ URL.revokeObjectURL(objectUrl); } + function resolveDownloadFileNameFromHeaders( + response: Response, + fallbackFileName: string, + ) { + const disposition = response.headers.get("content-disposition") ?? ""; + const encodedFileName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; + if (encodedFileName) { + try { + return decodeURIComponent(encodedFileName); + } catch { + return encodedFileName; + } + } + + return ( + disposition.match(/filename="?([^";]+)"?/i)?.[1]?.trim() || + fallbackFileName + ); + } + + async function readDownloadErrorMessage(response: Response) { + const fallback = "合同下载失败,请稍后再试"; + try { + const payload = (await response.json()) as { + error?: { message?: string }; + }; + return payload.error?.message || fallback; + } catch { + return fallback; + } + } + + function buildProductFactoryContractFallbackFileName( + type: ProductFactoryContractType, + ) { + const directOption = PRODUCT_FACTORY_DIRECT_CONTRACT_OPTIONS.find( + (option) => option.key === type, + ); + const standardOption = PRODUCT_FACTORY_STANDARD_CONTRACT_OPTIONS.find( + (option) => option.key === type, + ); + const contractLabel = + directOption?.label ?? + (standardOption ? `采购标准合同-${standardOption.label}` : "产品定厂合同"); + const productLabel = + formValues.spu.trim() || + openingReference.productName.trim() || + currentProductDisplayName; + + return `${contractLabel}_${productLabel || "产品定厂"}.docx`; + } + + async function handleDownloadProductFactoryContract( + type: ProductFactoryContractType, + ) { + if (downloadingProductFactoryContractType) return; + if (!canDownloadProductFactoryContract) { + setSaveMessage("当前账号没有下载产品定厂合同的权限"); + setSaveMessageType("error"); + return; + } + if (!productId) { + setSaveMessage("请先保存产品,再下载产品定厂合同"); + setSaveMessageType("error"); + return; + } + if (displayedProductFactoryRows.length === 0) { + setSaveMessage("当前批次没有可下载的产品定厂 SKU"); + setSaveMessageType("error"); + return; + } + + const fallbackFileName = buildProductFactoryContractFallbackFileName(type); + setDownloadingProductFactoryContractType(type); + setProductFactoryContractMenuOpen(false); + + try { + if (mode === "edit" && !isReadOnly) { + const saved = await persistEditedProduct({ + successMessage: "已同步当前产品定厂信息,准备下载合同", + errorFallbackMessage: "产品定厂信息保存失败,请稍后再试", + }); + if (!saved) return; + } + + startGeneratedDownloadProgress(fallbackFileName); + const params = new URLSearchParams({ + type, + batch: String(activeDownstreamSkuBatch), + }); + const response = await fetch( + `/api/intake/non-integrated/${encodeURIComponent( + productId, + )}/contracts?${params.toString()}`, + { + method: "GET", + credentials: "same-origin", + cache: "no-store", + }, + ); + + updateGeneratedDownloadProgress(65, fallbackFileName); + + if (!response.ok) { + throw new Error(await readDownloadErrorMessage(response)); + } + + const blob = await response.blob(); + const fileName = resolveDownloadFileNameFromHeaders( + response, + fallbackFileName, + ); + updateGeneratedDownloadProgress(90, fileName); + downloadBlob(blob, fileName); + finishDownloadProgress(fileName); + setSaveMessage("合同已生成并开始下载"); + setSaveMessageType("success"); + } catch (error) { + setSaveMessage( + error instanceof Error ? error.message : "合同下载失败,请稍后再试", + ); + setSaveMessageType("error"); + setDownloadProgress(null); + } finally { + setDownloadingProductFactoryContractType(null); + } + } + function buildWorkspaceZipFileName(label?: string) { const productName = sanitizeZipPathSegment( formValues.spu.trim() || @@ -17587,6 +19371,10 @@ export default function NonIntegratedDetailClient({ } async function handleBatchDownloadSelectedFiles() { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } await handleDownloadWorkspaceFiles( uploadFiles.filter((file) => selectedUploadFileIds.includes(file.id)), "已选资料", @@ -17597,6 +19385,10 @@ export default function NonIntegratedDetailClient({ files: NonIntegratedUploadFile[], label?: string, ) { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } if (files.length === 0) return; try { @@ -17693,6 +19485,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dUvConfig: null, }, })); } else if (selectedSupplementSourceSku) { @@ -17705,6 +19498,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dUvConfig: null, } : item, ), @@ -17718,6 +19512,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dUvConfig: null, }, })); } @@ -18101,7 +19896,6 @@ export default function NonIntegratedDetailClient({ const targetItem = skuItems.find((item) => item.id === id); if (!targetItem) return; if ( - isStockProductRecord || isSkuItemFactoryConfirmed(targetItem) || isImportedSkuContentLocked(targetItem) ) @@ -18155,7 +19949,17 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + function denyWorkspaceDownloadForCurrentUser() { + setSaveMessage("当前账号没有下载资料的权限"); + setSaveMessageType("error"); + setDownloadProgress(null); + } + async function handleExportSkuWorkbook() { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } if (derivedSkuItems.length === 0) return; const normalizedSpu = formValues.spu.trim() || "SKU资料"; @@ -18322,6 +20126,10 @@ export default function NonIntegratedDetailClient({ } async function handleExportBomWorkbook() { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } if (isExportingBom || selectedBomRows.length === 0) return; const fileName = buildNonIntegratedBomFileName({ @@ -18449,7 +20257,7 @@ export default function NonIntegratedDetailClient({ function updateSelectedReviewSheet( updater: (current: NonIntegratedReviewSheet) => NonIntegratedReviewSheet, ) { - if (isReadOnly || !effectiveSelectedReviewRoundId) return; + if (!canEditReviewSheet || !effectiveSelectedReviewRoundId) return; setReviewRounds((current) => current.map((round) => @@ -18466,7 +20274,115 @@ export default function NonIntegratedDetailClient({ } function updateReviewSheetField(key: ReviewSheetFieldKey, value: string) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; + if ( + key === "reviewResult" && + isReviewNodeLocked && + reviewSheet.reviewResult.trim() + ) + return; + + if ( + isReviewNodeLocked && + (key === "barcode" || key === "reviewResult") + ) { + const nextReviewRounds = reviewRounds.map((round) => + round.id === effectiveSelectedReviewRoundId + ? { + ...round, + reviewSheet: { + ...round.reviewSheet, + [key]: value, + }, + } + : round, + ); + const selectedLockedReviewResult = + key === "reviewResult" ? value.trim() : (reviewSheet.reviewResult?.trim() ?? ""); + let nextConfirmations = documentWorkspaceConfirmations; + let nextApprovedAt = documentWorkspaceApprovedAt; + let nextFinishedSampleShipmentRows: + | NonIntegratedFinishedSampleShipmentRow[] + | undefined; + + if ( + key === "reviewResult" && + !REVIEW_RESULT_OPTIONS.includes( + selectedLockedReviewResult as (typeof REVIEW_RESULT_OPTIONS)[number], + ) + ) { + setSaveMessage("请选择有效的审样结果。"); + setSaveMessageType("error"); + return; + } + + if (selectedLockedReviewResult === REVIEW_RESULT_NO_MODIFICATION) { + const autoShipmentRows = buildNoModificationFinishedSampleShipmentRows( + derivedSkuItems, + key === "barcode" ? value : reviewSheet.barcode, + ); + if (!autoShipmentRows) { + setSaveMessage("当前条码没有匹配到 SKU 条码,请选择已有 SKU 条码。"); + setSaveMessageType("error"); + return; + } + + nextFinishedSampleShipmentRows = autoShipmentRows; + nextConfirmations = { + ...documentWorkspaceConfirmations, + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: true, + }; + nextApprovedAt = { + ...documentWorkspaceApprovedAt, + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: + documentWorkspaceApprovedAt[FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY] ?? + new Date().toISOString(), + }; + setFinishedSampleShipmentRows(nextFinishedSampleShipmentRows); + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); + } + + setReviewRounds(nextReviewRounds); + if (documentWorkspaceRevisionSignatureRef.current) { + documentWorkspaceRevisionSignatureRef.current = { + ...documentWorkspaceRevisionSignatureRef.current, + [REVIEW_WORKSPACE_CATEGORY]: JSON.stringify({ + reviewRounds: nextReviewRounds, + files: buildDocumentWorkspaceRevisionFileEntries( + uploadFiles, + REVIEW_WORKSPACE_CATEGORY, + ), + }), + ...(nextFinishedSampleShipmentRows + ? { + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: JSON.stringify( + nextFinishedSampleShipmentRows, + ), + } + : {}), + }; + } + setSaveMessage(""); + setSaveMessageType("success"); + void persistEditedProduct({ + successMessage: + key === "barcode" ? "已更新评审条码绑定" : "已补充审样结果", + errorFallbackMessage: + key === "barcode" + ? "评审条码绑定保存失败,请稍后再试" + : "审样结果保存失败,请稍后再试", + overrides: { + reviewRounds: nextReviewRounds, + documentWorkspaceConfirmations: nextConfirmations, + documentWorkspaceApprovedAt: nextApprovedAt, + ...(nextFinishedSampleShipmentRows + ? { finishedSampleShipmentRows: nextFinishedSampleShipmentRows } + : {}), + }, + }); + return; + } updateSelectedReviewSheet((current) => ({ ...current, @@ -18479,7 +20395,7 @@ export default function NonIntegratedDetailClient({ key: ReviewIssueFieldKey, value: string, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => ({ ...current, @@ -18518,7 +20434,7 @@ export default function NonIntegratedDetailClient({ } function handleAddReviewIssue() { - if (isReadOnly) return; + if (!canEditReviewSheet) return; const nextIssue = createEmptyReviewIssue(); updateSelectedReviewSheet((current) => ({ @@ -18529,7 +20445,7 @@ export default function NonIntegratedDetailClient({ } function handleDeleteReviewIssue(issueId: string) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => { const remainingIssues = current.issues.filter( @@ -18550,7 +20466,7 @@ export default function NonIntegratedDetailClient({ } function handleAddReviewRound() { - if (isReadOnly) return; + if (!canEditReviewSheet) return; if ( !window.confirm( "确定新增轮次吗?\n点击确认后会新增一轮空白评审;真的确认吗?", @@ -18612,7 +20528,7 @@ export default function NonIntegratedDetailClient({ async function handleReviewSheetProductImageChange( event: ChangeEvent, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; const file = event.target.files?.[0]; if (!file) return; @@ -18641,14 +20557,12 @@ export default function NonIntegratedDetailClient({ event.target.value = ""; } - async function handleReviewIssueImageChange( + async function uploadReviewIssueImageFiles( issueId: string, target: ReviewIssueImageTarget, - event: ChangeEvent, + files: File[], ) { - if (isReadOnly) return; - - const files = Array.from(event.target.files ?? []); + if (!canEditReviewSheet) return; if (files.length === 0) return; try { @@ -18703,22 +20617,51 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("error"); setUploadProgress(null); } + } + + async function handleReviewIssueImageChange( + issueId: string, + target: ReviewIssueImageTarget, + event: ChangeEvent, + ) { + await uploadReviewIssueImageFiles( + issueId, + target, + Array.from(event.target.files ?? []), + ); event.target.value = ""; } + async function handleReviewIssueImagePaste( + issueId: string, + target: ReviewIssueImageTarget, + event: ClipboardEvent, + ) { + if (!canEditReviewSheet) return; + + const files = getImageFilesFromClipboardData(event.clipboardData); + if (files.length === 0) return; + + event.preventDefault(); + await uploadReviewIssueImageFiles(issueId, target, files); + } + function handleDeleteReviewIssueImage( issueId: string, target: ReviewIssueImageTarget, imageId: string, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => ({ ...current, issues: updateReviewIssueList(current.issues, issueId, (issue) => { const imageKey = target === "issue" ? "issueImages" : "referenceImages"; + const primaryImageKey = target === "issue" ? "issueImage" : "referenceImage"; + const primaryImageNameKey = + target === "issue" ? "issueImageName" : "referenceImageName"; const currentImages = normalizeReviewIssueImages( issue[imageKey], target === "issue" ? issue.issueImage : issue.referenceImage, @@ -18738,6 +20681,8 @@ export default function NonIntegratedDetailClient({ return { ...issue, + [primaryImageKey]: "", + [primaryImageNameKey]: "", [imageKey]: nextImages, }; }), @@ -18749,7 +20694,7 @@ export default function NonIntegratedDetailClient({ target: ReviewIssueImageTarget, imageId: string, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => ({ ...current, @@ -18783,6 +20728,10 @@ export default function NonIntegratedDetailClient({ } async function handleExportReviewSheet() { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } if (isExportingReviewSheet) return; setIsExportingReviewSheet(true); @@ -18794,6 +20743,8 @@ export default function NonIntegratedDetailClient({ productImageName: reviewSheet.productImageName.trim(), size: reviewSheet.size.trim(), material: reviewSheet.material.trim(), + barcode: reviewSheet.barcode?.trim() ?? "", + reviewResult: reviewSheet.reviewResult?.trim() ?? "", color: reviewSheet.color.trim(), issues: reviewSheet.issues .map((issue) => syncReviewIssuePrimaryImages(issue)) @@ -18827,6 +20778,10 @@ export default function NonIntegratedDetailClient({ } async function handleExportQualityInspection() { + if (!canDownloadWorkspaceFiles) { + denyWorkspaceDownloadForCurrentUser(); + return; + } if (isExportingQualityInspection) return; const fileName = buildNonIntegratedQualityInspectionFileName( @@ -18878,7 +20833,14 @@ export default function NonIntegratedDetailClient({ const productCreatedAt = new Date().toISOString(); setIsSubmitting(true); try { - const stockSpu = normalizedStockProductSpu; + const createBrand = + normalizeNonIntegratedBrand(formValues.brand) || NON_INTEGRATED_DEFAULT_BRAND; + const stockSpu = isStockProductCreate + ? applyBrandPrefixToNonIntegratedCode( + normalizedStockProductSpu, + createBrand, + ) + : normalizedStockProductSpu; const draftProductName = isStockProductCreate ? openingReference.productName.trim() || stockSpu @@ -18887,8 +20849,6 @@ export default function NonIntegratedDetailClient({ isStockProductCreate ? stockSpu : openingReference.temporaryCode.trim(); - const createBrand = - normalizeNonIntegratedBrand(formValues.brand) || NON_INTEGRATED_DEFAULT_BRAND; const createAdaptableBrands = normalizeNonIntegratedBrandList([ createBrand, ...openingReference.adaptableBrands, @@ -18896,7 +20856,13 @@ export default function NonIntegratedDetailClient({ ]).filter((brand) => BRAND_OPTIONS.includes(brand)); let createFormalSpu = isStockProductCreate ? stockSpu - : formValues.spu.trim(); + : formValues.spu.trim() || + (draftTemporaryCode + ? applyBrandPrefixToNonIntegratedCode( + draftTemporaryCode, + createBrand, + ) + : ""); if (!isStockProductCreate && isSeriesDevelopment) { const productCategory = @@ -18911,7 +20877,10 @@ export default function NonIntegratedDetailClient({ throw new Error("当前类目还没有配置SPU规则,请先维护SPU规则库。"); } - createFormalSpu = assignment.spu; + createFormalSpu = applyBrandPrefixToNonIntegratedCode( + assignment.spu, + createBrand, + ); } const nextOpeningReference = normalizeNonIntegratedOpeningReference({ @@ -19446,7 +21415,11 @@ export default function NonIntegratedDetailClient({ ); } @@ -19515,31 +21488,34 @@ export default function NonIntegratedDetailClient({ onChange={(event) => void handleUploadPackagingFiles(event)} /> packagingModel3dInputRef.current?.click()} disabled={!selectedPackagingStyleEntry} > - 上传 + 上传模型 void handlePackagingModel3dChange(event)} /> )} - - effectiveSelectedWorkspaceFile - ? handleDownloadUploadFile(effectiveSelectedWorkspaceFile) - : undefined - } - disabled={!effectiveSelectedWorkspaceFile} - > - 下载 - + {canDownloadWorkspaceFiles ? ( + + effectiveSelectedWorkspaceFile + ? handleDownloadUploadFile(effectiveSelectedWorkspaceFile) + : undefined + } + disabled={!effectiveSelectedWorkspaceFile} + > + 下载 + + ) : null} {isReadOnly ? null : ( @@ -19716,9 +21692,113 @@ export default function NonIntegratedDetailClient({

{ + setPreviewNodeNames(nodeNames); + setPreviewNodeOffsetMap((current) => { + const validNodeNameSet = new Set(nodeNames); + const nextEntries = Object.entries(current).filter( + ([nodeName]) => validNodeNameSet.has(nodeName), + ); + if (nextEntries.length === Object.keys(current).length) { + return current; + } + return Object.fromEntries(nextEntries); + }); + setPreviewNodeRotationMap((current) => { + const validNodeNameSet = new Set(nodeNames); + const nextEntries = Object.entries(current).filter( + ([nodeName]) => validNodeNameSet.has(nodeName), + ); + if (nextEntries.length === Object.keys(current).length) { + return current; + } + return Object.fromEntries(nextEntries); + }); + const nextSelectedNodeNames = + selectedPreviewNodeNamesRef.current.filter((nodeName) => + nodeNames.includes(nodeName), + ); + selectedPreviewNodeNamesRef.current = nextSelectedNodeNames; + setSelectedPreviewNodeNames(nextSelectedNodeNames); + setSelectedPreviewNodeName((current) => + current && nodeNames.includes(current) + ? current + : (nextSelectedNodeNames[nextSelectedNodeNames.length - 1] ?? + null), + ); + const validNodeNameSet = new Set(nodeNames); + const nextMaterialEntries = Object.entries( + selectedPackagingModelNodeMaterialIds, + ).filter(([nodeName]) => validNodeNameSet.has(nodeName)); + if ( + nextMaterialEntries.length !== + Object.keys(selectedPackagingModelNodeMaterialIds).length + ) { + persistPreviewNodeMaterialIds( + Object.fromEntries(nextMaterialEntries), + ); + } + }} + onNodeSelect={handlePreviewNodeSelect} + onNodeContextMenu={ + canEditPreviewModelMaterial && model3dPreviewUrl + ? handlePreviewNodeContextMenu + : undefined + } + overlayTopRight={ + !isReadOnly && selectedPackagingStyleEntry ? ( + packagingModel3dInputRef.current?.click()} + > + {model3dPreviewUrl ? "替换3D模型" : "上传3D模型"} + + ) : null + } + overlayBottomCenter={ + selectedPackagingModelFileName ? ( +
+ + {selectedPackagingModelFileName} + +
+ ) : !isReadOnly && selectedPackagingStyleEntry ? ( + packagingModel3dInputRef.current?.click()} + > + 上传模型 + + ) : null + } />
@@ -19769,12 +21849,16 @@ export default function NonIntegratedDetailClient({
{renderFirstQualityWorkspacePanelTabs()}
- handleDownloadWorkspaceFiles(factoryQualityFiles)} - disabled={factoryQualityFiles.length === 0} - > - 批量下载 - + {canDownloadWorkspaceFiles ? ( + + handleDownloadWorkspaceFiles(factoryQualityFiles) + } + disabled={factoryQualityFiles.length === 0} + > + 批量下载 + + ) : null} void handleConfirmFirstQualityRows()} disabled={ @@ -19827,24 +21911,30 @@ export default function NonIntegratedDetailClient({ /> )} - - handleDownloadWorkspaceFiles(qualityStandardFiles) - } - disabled={qualityStandardFiles.length === 0} - > - 批量下载 - - - effectiveSelectedWorkspaceFile - ? handleDownloadUploadFile(effectiveSelectedWorkspaceFile) - : undefined - } - disabled={!effectiveSelectedWorkspaceFile} - > - 下载 - + {canDownloadWorkspaceFiles ? ( + <> + + handleDownloadWorkspaceFiles(qualityStandardFiles) + } + disabled={qualityStandardFiles.length === 0} + > + 批量下载 + + + effectiveSelectedWorkspaceFile + ? handleDownloadUploadFile( + effectiveSelectedWorkspaceFile, + ) + : undefined + } + disabled={!effectiveSelectedWorkspaceFile} + > + 下载 + + + ) : null} {isReadOnly ? null : ( @@ -19937,6 +22027,39 @@ export default function NonIntegratedDetailClient({ /> ); }; + const renderFactoryImageCell = ( + row: NonIntegratedFactoryRow, + index: number, + ) => { + const sku = derivedSkuItemById.get(row.skuId); + if (!sku?.image.trim()) { + return ( +
+ 无图 +
+ ); + } + + return ( + + ); + }; return (
{renderInheritedSkuBatchTabs()} -
+
+ {canDownloadProductFactoryContract ? ( +
+ {PRODUCT_FACTORY_DIRECT_CONTRACT_OPTIONS.map((option) => { + const isDownloading = + downloadingProductFactoryContractType === option.key; + return ( + + ); + })} +
+ + {productFactoryContractMenuOpen ? ( +
+ {PRODUCT_FACTORY_STANDARD_CONTRACT_OPTIONS.map( + (option) => ( + + ), + )} +
+ ) : null} +
+
+ ) : null}
{PRODUCT_FACTORY_TAB_OPTIONS.map((option) => (