diff --git a/furniture-3d-system/prisma/migrations/20260729143000_link_intake_ai_render_assets/migration.sql b/furniture-3d-system/prisma/migrations/20260729143000_link_intake_ai_render_assets/migration.sql new file mode 100644 index 00000000..4374bf0b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260729143000_link_intake_ai_render_assets/migration.sql @@ -0,0 +1,188 @@ +CREATE TYPE "IntakeAiRenderOutputMode" AS ENUM ('WHITE_BACKGROUND', 'EIGHT_VIEW', 'SCENE'); +CREATE TYPE "IntakeAiRenderStorageClass" AS ENUM ('TEMPORARY', 'PERMANENT'); + +CREATE TABLE "IntakeProductModelAsset" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "modelKey" TEXT NOT NULL, + "displayName" TEXT NOT NULL DEFAULT '', + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "IntakeProductModelAsset_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "IntakeProductModelVersion" ( + "id" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "versionNo" INTEGER NOT NULL, + "originalFileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "checksumSha256" TEXT NOT NULL DEFAULT '', + "sourceStorageKey" TEXT NOT NULL, + "sourceUrl" TEXT NOT NULL, + "previewStorageKey" TEXT, + "previewUrl" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "IntakeProductModelVersion_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "IntakeSkuAiProfile" ( + "id" TEXT NOT NULL, + "skuItemId" TEXT NOT NULL, + "currentModelVersionId" TEXT, + "currentMaterialVersionId" TEXT, + "createdBy" TEXT NOT NULL, + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "IntakeSkuAiProfile_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "IntakeSkuAiMaterialVersion" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "versionNo" INTEGER NOT NULL, + "materialColor" TEXT NOT NULL DEFAULT '#d8c6ad', + "textureStorageKey" TEXT, + "textureUrl" TEXT, + "nodeMaterials" JSONB, + "uvConfig" JSONB, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "IntakeSkuAiMaterialVersion_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "AssistedAssemblyAiRender" +ADD COLUMN "intakeSkuItemId" TEXT, +ADD COLUMN "modelVersionId" TEXT, +ADD COLUMN "materialVersionId" TEXT, +ADD COLUMN "outputMode" "IntakeAiRenderOutputMode", +ADD COLUMN "batchId" TEXT, +ADD COLUMN "viewName" TEXT, +ADD COLUMN "viewIndex" INTEGER, +ADD COLUMN "storageClass" "IntakeAiRenderStorageClass" NOT NULL DEFAULT 'TEMPORARY'; + +CREATE UNIQUE INDEX "IntakeProductModelAsset_productId_modelKey_key" +ON "IntakeProductModelAsset"("productId", "modelKey"); +CREATE INDEX "IntakeProductModelAsset_productId_createdAt_idx" +ON "IntakeProductModelAsset"("productId", "createdAt"); +CREATE INDEX "IntakeProductModelAsset_createdBy_idx" +ON "IntakeProductModelAsset"("createdBy"); + +CREATE UNIQUE INDEX "IntakeProductModelVersion_assetId_versionNo_key" +ON "IntakeProductModelVersion"("assetId", "versionNo"); +CREATE INDEX "IntakeProductModelVersion_assetId_createdAt_idx" +ON "IntakeProductModelVersion"("assetId", "createdAt"); +CREATE INDEX "IntakeProductModelVersion_checksumSha256_idx" +ON "IntakeProductModelVersion"("checksumSha256"); +CREATE INDEX "IntakeProductModelVersion_createdBy_idx" +ON "IntakeProductModelVersion"("createdBy"); + +CREATE UNIQUE INDEX "IntakeSkuAiProfile_skuItemId_key" +ON "IntakeSkuAiProfile"("skuItemId"); +CREATE UNIQUE INDEX "IntakeSkuAiProfile_currentMaterialVersionId_key" +ON "IntakeSkuAiProfile"("currentMaterialVersionId"); +CREATE INDEX "IntakeSkuAiProfile_currentModelVersionId_idx" +ON "IntakeSkuAiProfile"("currentModelVersionId"); +CREATE INDEX "IntakeSkuAiProfile_createdBy_idx" +ON "IntakeSkuAiProfile"("createdBy"); + +CREATE UNIQUE INDEX "IntakeSkuAiMaterialVersion_profileId_versionNo_key" +ON "IntakeSkuAiMaterialVersion"("profileId", "versionNo"); +CREATE INDEX "IntakeSkuAiMaterialVersion_profileId_createdAt_idx" +ON "IntakeSkuAiMaterialVersion"("profileId", "createdAt"); +CREATE INDEX "IntakeSkuAiMaterialVersion_createdBy_idx" +ON "IntakeSkuAiMaterialVersion"("createdBy"); + +CREATE INDEX "AssistedAssemblyAiRender_intakeSkuItemId_createdAt_idx" +ON "AssistedAssemblyAiRender"("intakeSkuItemId", "createdAt"); +CREATE INDEX "AssistedAssemblyAiRender_intakeSkuItemId_outputMode_createdAt_idx" +ON "AssistedAssemblyAiRender"("intakeSkuItemId", "outputMode", "createdAt"); +CREATE INDEX "AssistedAssemblyAiRender_modelVersionId_idx" +ON "AssistedAssemblyAiRender"("modelVersionId"); +CREATE INDEX "AssistedAssemblyAiRender_materialVersionId_idx" +ON "AssistedAssemblyAiRender"("materialVersionId"); +CREATE INDEX "AssistedAssemblyAiRender_storageClass_createdAt_idx" +ON "AssistedAssemblyAiRender"("storageClass", "createdAt"); + +ALTER TABLE "IntakeProductModelAsset" +ADD CONSTRAINT "IntakeProductModelAsset_productId_fkey" +FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntakeProductModelAsset" +ADD CONSTRAINT "IntakeProductModelAsset_createdBy_fkey" +FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "IntakeProductModelVersion" +ADD CONSTRAINT "IntakeProductModelVersion_assetId_fkey" +FOREIGN KEY ("assetId") REFERENCES "IntakeProductModelAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntakeProductModelVersion" +ADD CONSTRAINT "IntakeProductModelVersion_createdBy_fkey" +FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "IntakeSkuAiProfile" +ADD CONSTRAINT "IntakeSkuAiProfile_skuItemId_fkey" +FOREIGN KEY ("skuItemId") REFERENCES "IntakeNonIntegratedSkuItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntakeSkuAiProfile" +ADD CONSTRAINT "IntakeSkuAiProfile_currentModelVersionId_fkey" +FOREIGN KEY ("currentModelVersionId") REFERENCES "IntakeProductModelVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "IntakeSkuAiProfile" +ADD CONSTRAINT "IntakeSkuAiProfile_createdBy_fkey" +FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "IntakeSkuAiMaterialVersion" +ADD CONSTRAINT "IntakeSkuAiMaterialVersion_profileId_fkey" +FOREIGN KEY ("profileId") REFERENCES "IntakeSkuAiProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntakeSkuAiMaterialVersion" +ADD CONSTRAINT "IntakeSkuAiMaterialVersion_createdBy_fkey" +FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "IntakeSkuAiProfile" +ADD CONSTRAINT "IntakeSkuAiProfile_currentMaterialVersionId_fkey" +FOREIGN KEY ("currentMaterialVersionId") REFERENCES "IntakeSkuAiMaterialVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AssistedAssemblyAiRender" +ADD CONSTRAINT "AssistedAssemblyAiRender_intakeSkuItemId_fkey" +FOREIGN KEY ("intakeSkuItemId") REFERENCES "IntakeNonIntegratedSkuItem"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AssistedAssemblyAiRender" +ADD CONSTRAINT "AssistedAssemblyAiRender_modelVersionId_fkey" +FOREIGN KEY ("modelVersionId") REFERENCES "IntakeProductModelVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AssistedAssemblyAiRender" +ADD CONSTRAINT "AssistedAssemblyAiRender_materialVersionId_fkey" +FOREIGN KEY ("materialVersionId") REFERENCES "IntakeSkuAiMaterialVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +UPDATE "AssistedAssemblyAiRender" +SET + "outputMode" = CASE + WHEN "params"->>'mode' = 'white-background' THEN 'WHITE_BACKGROUND'::"IntakeAiRenderOutputMode" + WHEN "params"->>'mode' = 'eight-view' THEN 'EIGHT_VIEW'::"IntakeAiRenderOutputMode" + WHEN "params"->>'mode' = 'scene' THEN 'SCENE'::"IntakeAiRenderOutputMode" + ELSE NULL + END, + "batchId" = NULLIF("params"->>'batchId', ''), + "viewName" = NULLIF("params"->>'viewName', ''), + "viewIndex" = CASE + WHEN ("params"->>'viewIndex') ~ '^[0-9]+$' THEN ("params"->>'viewIndex')::INTEGER + ELSE NULL + END; + +WITH exact_matches AS ( + SELECT render."id" AS "renderId", MIN(item."id") AS "skuItemId" + FROM "AssistedAssemblyAiRender" render + JOIN "AiRenderWorkbenchSku" workbench_sku ON workbench_sku."id" = render."workbenchSkuId" + JOIN "AiRenderWorkbenchSpu" workbench_spu ON workbench_spu."id" = workbench_sku."spuId" + JOIN "IntakeNonIntegratedSkuItem" item + ON UPPER(BTRIM(item."barcode")) = UPPER(BTRIM(workbench_sku."name")) + JOIN "IntakeNonIntegratedProduct" product + ON product."id" = item."productId" + AND UPPER(BTRIM(product."spu")) = UPPER(BTRIM(workbench_spu."name")) + WHERE BTRIM(item."barcode") <> '' + GROUP BY render."id" + HAVING COUNT(*) = 1 +) +UPDATE "AssistedAssemblyAiRender" render +SET "intakeSkuItemId" = exact_matches."skuItemId" +FROM exact_matches +WHERE render."id" = exact_matches."renderId"; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 1fca06ee..efb79447 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -257,6 +257,7 @@ model IntakeNonIntegratedProduct { pricingRows IntakeNonIntegratedPricingRow[] productLimitRows IntakeNonIntegratedProductLimitRow[] profile IntakeNonIntegratedProductProfile? + modelAssets IntakeProductModelAsset[] @@index([spu]) @@index([spuBase]) @@ -394,6 +395,8 @@ model IntakeNonIntegratedSkuItem { contentLocked Boolean @default(false) dataQualityStatus IntakeNonIntegratedSkuDataQualityStatus @default(INCOMPLETE) missingRequiredFields String[] @default([]) + aiProfile IntakeSkuAiProfile? + aiRenders AssistedAssemblyAiRender[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -408,6 +411,88 @@ model IntakeNonIntegratedSkuItem { @@index([sourceTable, sourceSkuId]) } +model IntakeProductModelAsset { + id String @id @default(cuid()) + productId String + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + modelKey String + displayName String @default("") + versions IntakeProductModelVersion[] + createdBy String + creator User @relation("IntakeProductModelAssetCreator", fields: [createdBy], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([productId, modelKey]) + @@index([productId, createdAt]) + @@index([createdBy]) +} + +model IntakeProductModelVersion { + id String @id @default(cuid()) + assetId String + asset IntakeProductModelAsset @relation(fields: [assetId], references: [id], onDelete: Cascade) + versionNo Int + originalFileName String + mimeType String + fileSize Int + checksumSha256 String @default("") + sourceStorageKey String @db.Text + sourceUrl String @db.Text + previewStorageKey String? @db.Text + previewUrl String? @db.Text + createdBy String + creator User @relation("IntakeProductModelVersionCreator", fields: [createdBy], references: [id], onDelete: Restrict) + skuProfiles IntakeSkuAiProfile[] + aiRenders AssistedAssemblyAiRender[] + createdAt DateTime @default(now()) + + @@unique([assetId, versionNo]) + @@index([assetId, createdAt]) + @@index([checksumSha256]) + @@index([createdBy]) +} + +model IntakeSkuAiProfile { + id String @id @default(cuid()) + skuItemId String @unique + skuItem IntakeNonIntegratedSkuItem @relation(fields: [skuItemId], references: [id], onDelete: Cascade) + currentModelVersionId String? + currentModelVersion IntakeProductModelVersion? @relation(fields: [currentModelVersionId], references: [id], onDelete: SetNull) + currentMaterialVersionId String? @unique + currentMaterialVersion IntakeSkuAiMaterialVersion? @relation("CurrentIntakeSkuAiMaterialVersion", fields: [currentMaterialVersionId], references: [id], onDelete: SetNull) + materialVersions IntakeSkuAiMaterialVersion[] @relation("IntakeSkuAiMaterialVersions") + createdBy String + updatedBy String? + creator User @relation("IntakeSkuAiProfileCreator", fields: [createdBy], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([currentModelVersionId]) + @@index([createdBy]) +} + +model IntakeSkuAiMaterialVersion { + id String @id @default(cuid()) + profileId String + profile IntakeSkuAiProfile @relation("IntakeSkuAiMaterialVersions", fields: [profileId], references: [id], onDelete: Cascade) + currentForProfile IntakeSkuAiProfile? @relation("CurrentIntakeSkuAiMaterialVersion") + versionNo Int + materialColor String @default("#d8c6ad") + textureStorageKey String? @db.Text + textureUrl String? @db.Text + nodeMaterials Json? + uvConfig Json? + createdBy String + creator User @relation("IntakeSkuAiMaterialVersionCreator", fields: [createdBy], references: [id], onDelete: Restrict) + aiRenders AssistedAssemblyAiRender[] + createdAt DateTime @default(now()) + + @@unique([profileId, versionNo]) + @@index([profileId, createdAt]) + @@index([createdBy]) +} + model IntakeNonIntegratedPricingRow { id String @id productId String @@ -1366,6 +1451,18 @@ model AssistedAssemblyAiRender { workbenchSkuId String? workbenchSku AiRenderWorkbenchSku? @relation(fields: [workbenchSkuId], references: [id], onDelete: SetNull) + intakeSkuItemId String? + intakeSkuItem IntakeNonIntegratedSkuItem? @relation(fields: [intakeSkuItemId], references: [id], onDelete: SetNull) + modelVersionId String? + modelVersion IntakeProductModelVersion? @relation(fields: [modelVersionId], references: [id], onDelete: SetNull) + materialVersionId String? + materialVersion IntakeSkuAiMaterialVersion? @relation(fields: [materialVersionId], references: [id], onDelete: SetNull) + outputMode IntakeAiRenderOutputMode? + batchId String? + viewName String? + viewIndex Int? + storageClass IntakeAiRenderStorageClass @default(TEMPORARY) + prompt String @db.Text negativePrompt String? @db.Text params Json @@ -1388,6 +1485,11 @@ model AssistedAssemblyAiRender { @@index([createdBy, createdAt]) @@index([workbenchSpuId, createdAt]) @@index([workbenchSkuId, createdAt]) + @@index([intakeSkuItemId, createdAt]) + @@index([intakeSkuItemId, outputMode, createdAt]) + @@index([modelVersionId]) + @@index([materialVersionId]) + @@index([storageClass, createdAt]) @@index([status, createdAt]) } @@ -1788,6 +1890,17 @@ enum AssistedAssemblyAiRenderStatus { FAILED } +enum IntakeAiRenderOutputMode { + WHITE_BACKGROUND + EIGHT_VIEW + SCENE +} + +enum IntakeAiRenderStorageClass { + TEMPORARY + PERMANENT +} + enum AssetSkuSyncEventStatus { PENDING PROCESSING @@ -1879,6 +1992,10 @@ model User { createdAiRenderWorkbenchSpus AiRenderWorkbenchSpu[] @relation("AiRenderWorkbenchSpuCreator") createdAiRenderWorkbenchSkus AiRenderWorkbenchSku[] @relation("AiRenderWorkbenchSkuCreator") assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator") + createdIntakeProductModelAssets IntakeProductModelAsset[] @relation("IntakeProductModelAssetCreator") + createdIntakeProductModelVersions IntakeProductModelVersion[] @relation("IntakeProductModelVersionCreator") + createdIntakeSkuAiProfiles IntakeSkuAiProfile[] @relation("IntakeSkuAiProfileCreator") + createdIntakeSkuAiMaterialVersions IntakeSkuAiMaterialVersion[] @relation("IntakeSkuAiMaterialVersionCreator") collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") feedbacks Feedback[] @relation("FeedbackSubmitter") diff --git a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx index 7dd04849..2e80aca2 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx @@ -135,7 +135,7 @@ export default function AiRenderWorkbenchClient({ const [materialPartAnnotationsBySkuId, setMaterialPartAnnotationsBySkuId] = useState< Record >(() => storedUiState?.materialPartAnnotationsBySkuId ?? {}) - const [materialPartAnnotationInput, setMaterialPartAnnotationInput] = useState('金色金属拉手') + const [materialPartAnnotationInput, setMaterialPartAnnotationInput] = useState('') const [message, setMessage] = useState(null) const [optimizing, setOptimizing] = useState(false) const [optimExplanation, setOptimExplanation] = useState('') diff --git a/furniture-3d-system/src/app/ai-render-workbench/RenderInspectorProductPanel.tsx b/furniture-3d-system/src/app/ai-render-workbench/RenderInspectorProductPanel.tsx index 12c3a0d4..2ae68730 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/RenderInspectorProductPanel.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/RenderInspectorProductPanel.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { Box, ChevronRight, Cuboid, Plus, Search, Trash2, X } from 'lucide-react' +import { Box, ChevronRight, Cuboid, Search, X } from 'lucide-react' import { cn } from '@/lib/utils' import { SkuModelUploadControl } from './SkuModelUploadControl' import type { AiRenderSku, AiRenderSpu } from './types' @@ -31,21 +31,15 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr selectedSpuId, selectedSkuId, uploadingSkuId, - spu, loading, canWriteConfig, onSelectSpu, - onCreateSpu, - onRenameSpu, - onDeleteSpu, onSelectSku, - onCreateSku, - onUpdateSkuById, - onDeleteSku, onUploadSkuModel, } = props const [productQuery, setProductQuery] = useState('') + const [collapsedSpuIds, setCollapsedSpuIds] = useState>(() => new Set()) const normalizedProductQuery = productQuery.trim().toLowerCase() const filteredSpus = useMemo(() => { @@ -74,9 +68,9 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr setProductQuery(event.target.value)} - placeholder="查询 SPU / SKU" + placeholder="查询 SPU / 条码" className="h-full min-w-0 flex-1 bg-transparent text-[11px] font-bold text-[#2d3335] outline-none placeholder:text-[#5a6062]/35" - aria-label="查询 SPU 或 SKU" + aria-label="查询 SPU 或条码" /> {productQuery ? ( - - - ) : ( -
- 当前账号可查看和生成,但不能修改产品配置 -
- )} - -
{filteredSpus.length === 0 ? (
- {loading ? '正在加载共享工作台...' : '没有匹配的 SPU / SKU'} + {loading ? '正在加载产品资料...' : '没有匹配的 SPU / 条码'}
) : ( filteredSpus.map(({ spu: spuItem, skus: visibleSkus }) => { const isSpuSelected = spuItem.id === selectedSpuId - const showSkuList = isSpuSelected || Boolean(normalizedProductQuery) + const isSpuCollapsed = collapsedSpuIds.has(spuItem.id) + const showSkuList = + Boolean(normalizedProductQuery) || (isSpuSelected && !isSpuCollapsed) return (
@@ -168,28 +149,16 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr onSelectSpu(spuItem.id)} - onChange={(event) => onRenameSpu(spuItem.id, event.target.value)} - readOnly={!canWriteConfig} - placeholder="填写 SPU 名称" + readOnly className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35 read-only:cursor-default" aria-label="SPU 名称" />

{normalizedProductQuery - ? `${visibleSkus.length}/${spuItem.skus.length} 个 SKU` - : `${spuItem.skus.length} 个 SKU`} + ? `${visibleSkus.length}/${spuItem.skus.length} 个条码` + : `${spuItem.skus.length} 个条码`}

- {canWriteConfig ? ( - - ) : null} {showSkuList ? ( @@ -211,7 +180,7 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr type="button" onClick={() => onSelectSku(skuItem.id)} className="flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px] text-[#5a6062]/45 transition hover:bg-white hover:text-[#2d3335]" - aria-label={`选择 SKU ${skuItem.name || '未命名 SKU'}`} + aria-label={`选择条码 ${skuItem.name || '未命名条码'}`} > onSelectSku(skuItem.id)} - onChange={(event) => - onUpdateSkuById(skuItem.id, { name: event.target.value }) - } - readOnly={!canWriteConfig} - placeholder="填写 SKU 名称" + readOnly className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35 read-only:cursor-default" - aria-label="SKU 名称" + aria-label="条码" />

{skuItem.modelUrl ? '已配置 SKU 模型' : '还没有 SKU 模型'}

- {canWriteConfig ? ( - - ) : null} {isSkuSelected && canWriteConfig ? ( -
- - onUpdateSkuById(skuItem.id, { modelUrl: event.target.value }) - } - placeholder="粘贴 SKU 模型 URL,或上传 ZIP / 模型" - className="h-8 min-w-0 rounded-[5px] bg-white/70 px-2 text-[11px] font-medium text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/18" - aria-label="SKU 模型 URL" - /> +
item.referenceImageDataUrl), workspaceSnapshot: buildAiRenderWorkspaceSnapshot({ diff --git a/furniture-3d-system/src/app/ai-render-workbench/sceneGeneration.ts b/furniture-3d-system/src/app/ai-render-workbench/sceneGeneration.ts index 2f6bb9d5..647abcd0 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/sceneGeneration.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/sceneGeneration.ts @@ -143,7 +143,7 @@ export async function runSceneGeneration(params: SceneGenerationParams): Promise aspectRatio: taskAspectRatio, imageSize: taskImageSize, mode: 'scene', - workbenchSkuId: taskSku.id, + intakeSkuItemId: taskSku.id, referenceImageDataUrl, workspaceSnapshot: buildAiRenderWorkspaceSnapshot({ spu: taskSpu, diff --git a/furniture-3d-system/src/app/ai-render-workbench/types.ts b/furniture-3d-system/src/app/ai-render-workbench/types.ts index 9c58dd5a..be2f5fcc 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/types.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/types.ts @@ -5,6 +5,9 @@ import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-co export interface AiRenderSku { id: string + productId?: string + skuItemId?: string + barcode?: string name: string modelUrl: string thumbnailUrl?: string | null @@ -12,6 +15,10 @@ export interface AiRenderSku { materialColor: string materialTextureUrl?: string nodeMaterials?: Record + uvConfig?: Record + modelVersionId?: string | null + modelFileName?: string | null + materialVersionId?: string | null } export interface AiRenderNodeMaterial { diff --git a/furniture-3d-system/src/app/ai-render-workbench/useAiRenderWorkbenchCatalogController.ts b/furniture-3d-system/src/app/ai-render-workbench/useAiRenderWorkbenchCatalogController.ts index 9775e0e5..d3b5f8a7 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/useAiRenderWorkbenchCatalogController.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/useAiRenderWorkbenchCatalogController.ts @@ -2,20 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { - createAiRenderWorkbenchSku, - createAiRenderWorkbenchSpu, - deleteAiRenderWorkbenchSku, - deleteAiRenderWorkbenchSpu, fetchAiRenderWorkbench, - importAiRenderWorkbenchLocalDraft, - updateAiRenderWorkbenchSku, - updateAiRenderWorkbenchSpu, + saveIntakeAiRenderMaterial, + uploadIntakeAiRenderModel, } from '@/lib/open-product/platform-api' -import { extractPreviewModelFromZip } from '@/lib/model-preview-zip' import type { AiRenderWorkbenchAccess } from '@/lib/ai-render-workbench-permissions' import { - AI_RENDER_WORKBENCH_STORAGE_KEY, - isLegacySampleOnly, normalizeSpu, readStoredWorkbenchState, readStoredWorkbenchUiState, @@ -34,48 +26,6 @@ interface UseAiRenderWorkbenchCatalogControllerParams { type WorkbenchResponse = Awaited> -async function uploadSkuModelFile(file: File): Promise { - let uploadFile = file - const lowerName = file.name.toLowerCase() - - if (lowerName.endsWith('.zip')) { - const parsed = await extractPreviewModelFromZip(file) - if (!parsed.success) { - throw new Error(parsed.error || 'ZIP 中没有可用的模型文件') - } - uploadFile = parsed.modelFile - } - - const uploadFileName = uploadFile.name.toLowerCase() - if ( - !uploadFileName.endsWith('.glb') && - !uploadFileName.endsWith('.gltf') && - !uploadFileName.endsWith('.obj') - ) { - throw new Error('仅支持 .zip / .glb / .gltf / .obj 作为 SKU 模型文件') - } - - const formData = new FormData() - formData.append('file', uploadFile) - const response = await fetch('/api/upload/model', { - method: 'POST', - body: formData, - }) - const payload = (await response.json().catch(() => null)) as - | { - success?: boolean - data?: { url?: string } - error?: { message?: string } - } - | null - - if (!response.ok || !payload?.success || !payload.data?.url) { - throw new Error(payload?.error?.message || 'SKU 模型上传失败') - } - - return payload.data.url -} - export function useAiRenderWorkbenchCatalogController( params: UseAiRenderWorkbenchCatalogControllerParams, ) { @@ -131,30 +81,16 @@ export function useAiRenderWorkbenchCatalogController( void (async () => { setWorkbenchLoading(true) try { - let workbench = await fetchAiRenderWorkbench() - if ( - workbench.access.canWriteConfig && - workbench.spus.length === 0 && - legacyStoredWorkbenchState && - legacyStoredWorkbenchState.spus.length > 0 && - !isLegacySampleOnly(legacyStoredWorkbenchState.spus) - ) { - workbench = await importAiRenderWorkbenchLocalDraft({ - spus: legacyStoredWorkbenchState.spus, - records: legacyStoredWorkbenchState.records, - }) - if (typeof window !== 'undefined') { - window.localStorage.removeItem(AI_RENDER_WORKBENCH_STORAGE_KEY) - } - if (active) { - setMessage('已把本机旧草稿导入服务器共享工作台。') - } - } + const workbench = await fetchAiRenderWorkbench() + const urlParams = + typeof window === 'undefined' ? null : new URLSearchParams(window.location.search) + const urlProductId = urlParams?.get('productId')?.trim() || null + const urlSkuId = urlParams?.get('skuId')?.trim() || null if (!active) return applyWorkbenchData(workbench, { - selectedSpuId, - selectedSkuId, + selectedSpuId: urlProductId ?? selectedSpuId, + selectedSkuId: urlSkuId ?? selectedSkuId, }) } catch (error) { if (active) { @@ -174,17 +110,8 @@ export function useAiRenderWorkbenchCatalogController( }, []) const updateSpu = useCallback( - (spuId: string, patch: Partial) => { - if (!canWriteConfig) return - setSpus((current) => current.map((spu) => (spu.id === spuId ? { ...spu, ...patch } : spu))) - void updateAiRenderWorkbenchSpu(spuId, { - name: patch.name, - description: patch.description, - }).catch((error) => { - setMessage(error instanceof Error ? error.message : 'SPU 更新失败') - }) - }, - [canWriteConfig, setMessage], + (_spuId: string, _patch: Partial) => undefined, + [], ) const updateSku = useCallback( @@ -196,46 +123,32 @@ export function useAiRenderWorkbenchCatalogController( skus: spu.skus.map((sku) => (sku.id === skuId ? { ...sku, ...patch } : sku)), })), ) - void updateAiRenderWorkbenchSku(skuId, patch).catch((error) => { - setMessage(error instanceof Error ? error.message : 'SKU 更新失败') + const sku = spus.flatMap((spu) => spu.skus).find((item) => item.id === skuId) + if (!sku?.productId) return + void saveIntakeAiRenderMaterial({ + productId: sku.productId, + skuDatabaseId: sku.id, + materialColor: patch.materialColor ?? sku.materialColor, + materialTextureUrl: patch.materialTextureUrl ?? sku.materialTextureUrl, + nodeMaterials: patch.nodeMaterials ?? sku.nodeMaterials, + uvConfig: patch.uvConfig ?? sku.uvConfig, + }).catch((error) => { + setMessage(error instanceof Error ? error.message : '条码材质保存失败') }) }, - [canWriteConfig, setMessage], + [canWriteConfig, setMessage, spus], ) const createSpu = useCallback(async () => { - if (!canWriteConfig) return false - setMessage(null) - try { - const workbench = await createAiRenderWorkbenchSpu({ - name: `新 SPU ${spus.length + 1}`, - }) - const newSpu = workbench.spus[workbench.spus.length - 1] - applyWorkbenchData(workbench, { - selectedSpuId: newSpu?.id ?? null, - selectedSkuId: newSpu?.skus[0]?.id ?? null, - }) - return true - } catch (error) { - setMessage(error instanceof Error ? error.message : 'SPU 创建失败') - return false - } - }, [applyWorkbenchData, canWriteConfig, setMessage, spus.length]) + return false + }, []) const deleteSpu = useCallback( async (spuId: string) => { - if (!canWriteConfig) return false - try { - const workbench = await deleteAiRenderWorkbenchSpu(spuId) - applyWorkbenchData(workbench) - setMessage('SPU 已删除') - return true - } catch (error) { - setMessage(error instanceof Error ? error.message : 'SPU 删除失败') - return false - } + void spuId + return false }, - [applyWorkbenchData, canWriteConfig, setMessage], + [], ) const selectSpu = useCallback( @@ -248,46 +161,15 @@ export function useAiRenderWorkbenchCatalogController( ) const createSku = useCallback(async () => { - if (!canWriteConfig || !selectedSpu) return false - try { - const workbench = await createAiRenderWorkbenchSku({ - spuId: selectedSpu.id, - name: `新 SKU ${selectedSpu.skus.length + 1}`, - }) - const nextSpu = workbench.spus.find((spu) => spu.id === selectedSpu.id) - const newSku = nextSpu?.skus[nextSpu.skus.length - 1] - applyWorkbenchData(workbench, { - selectedSpuId: selectedSpu.id, - selectedSkuId: newSku?.id ?? null, - }) - return true - } catch (error) { - setMessage(error instanceof Error ? error.message : 'SKU 创建失败') - return false - } - }, [applyWorkbenchData, canWriteConfig, selectedSpu, setMessage]) + return false + }, []) const deleteSku = useCallback( async (skuId: string) => { - if (!canWriteConfig) return false - try { - const parentSpu = spus.find((spuItem) => spuItem.skus.some((skuItem) => skuItem.id === skuId)) - const workbench = await deleteAiRenderWorkbenchSku(skuId) - const nextSpu = parentSpu - ? workbench.spus.find((spu) => spu.id === parentSpu.id) - : workbench.spus[0] - applyWorkbenchData(workbench, { - selectedSpuId: nextSpu?.id ?? null, - selectedSkuId: nextSpu?.skus[0]?.id ?? null, - }) - setMessage('SKU 已删除') - return true - } catch (error) { - setMessage(error instanceof Error ? error.message : 'SKU 删除失败') - return false - } + void skuId + return false }, - [applyWorkbenchData, canWriteConfig, setMessage, spus], + [], ) const uploadSkuModel = useCallback( @@ -295,19 +177,25 @@ export function useAiRenderWorkbenchCatalogController( if (!canWriteConfig) return false const parentSpu = spus.find((spuItem) => spuItem.skus.some((skuItem) => skuItem.id === skuId)) + const sku = parentSpu?.skus.find((item) => item.id === skuId) + if (!parentSpu || !sku?.productId || !sku.skuItemId) { + setMessage('当前条码缺少产品归属,不能上传模型') + return false + } setUploadingSkuId(skuId) setMessage(null) try { - const url = await uploadSkuModelFile(file) - const workbench = await updateAiRenderWorkbenchSku(skuId, { modelUrl: url }) - const nextParentSpu = workbench.spus.find((spuItem) => - spuItem.skus.some((skuItem) => skuItem.id === skuId), - ) + await uploadIntakeAiRenderModel({ + productId: sku.productId, + skuItemId: sku.skuItemId, + file, + }) + const workbench = await fetchAiRenderWorkbench() applyWorkbenchData(workbench, { - selectedSpuId: nextParentSpu?.id ?? parentSpu?.id ?? null, + selectedSpuId: parentSpu.id, selectedSkuId: skuId, }) - setMessage('SKU 模型已上传,可以在主视窗预览。') + setMessage('共享模型已上传,同型号未单独调整的条码已同步带出。') return true } catch (error) { setMessage(error instanceof Error ? error.message : 'SKU 模型上传失败') diff --git a/furniture-3d-system/src/app/ai-render-workbench/useDetailSelectionController.ts b/furniture-3d-system/src/app/ai-render-workbench/useDetailSelectionController.ts index 7088e078..c8f4aa5f 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/useDetailSelectionController.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/useDetailSelectionController.ts @@ -16,12 +16,12 @@ type UseDetailSelectionControllerOptions = { exitMessage: string captureCurrentReferenceImage: () => string | null onCreateDetailReference: (params: { - workbenchSkuId: string + intakeSkuItemId: string referenceImageDataUrl: string }) => Promise onDeleteDetailReference: (params: { storageKey: string - workbenchSkuId: string | null + intakeSkuItemId: string | null }) => Promise onCommitDetailReference: (image: AiRenderTemporaryReferenceImage | null) => void onMessage: (message: string) => void @@ -96,7 +96,7 @@ export function useDetailSelectionController(options: UseDetailSelectionControll if (selectedSkuDetailReference?.storageKey) { await onDeleteDetailReference({ storageKey: selectedSkuDetailReference.storageKey, - workbenchSkuId: selectedSku?.id ?? null, + intakeSkuItemId: selectedSku?.id ?? null, }) } onCommitDetailReference(null) @@ -221,7 +221,7 @@ export function useDetailSelectionController(options: UseDetailSelectionControll } const savedDetailReference = await onCreateDetailReference({ - workbenchSkuId: taskSku.id, + intakeSkuItemId: taskSku.id, referenceImageDataUrl: croppedReferenceImageDataUrl, }) onCommitDetailReference(savedDetailReference) @@ -233,7 +233,7 @@ export function useDetailSelectionController(options: UseDetailSelectionControll ) { void onDeleteDetailReference({ storageKey: previousDetailReference.storageKey, - workbenchSkuId: taskSku.id, + intakeSkuItemId: taskSku.id, }).catch(() => undefined) } } catch (error) { diff --git a/furniture-3d-system/src/app/ai-render-workbench/whiteBackgroundGeneration.ts b/furniture-3d-system/src/app/ai-render-workbench/whiteBackgroundGeneration.ts index 2bf7efe8..3d276ae9 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/whiteBackgroundGeneration.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/whiteBackgroundGeneration.ts @@ -271,7 +271,7 @@ export async function runWhiteBackgroundGeneration( aspectRatio: taskAspectRatio, imageSize: taskImageSize, mode: 'white-background', - workbenchSkuId: taskSku.id, + intakeSkuItemId: taskSku.id, referenceImageDataUrl: dataUrl, structureReferenceImageDataUrls: structureReferenceImageDataUrls.length > 0 diff --git a/furniture-3d-system/src/app/ai-render-workbench/workbenchHelpers.ts b/furniture-3d-system/src/app/ai-render-workbench/workbenchHelpers.ts index efcecb9e..41147a1d 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/workbenchHelpers.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/workbenchHelpers.ts @@ -151,7 +151,10 @@ export function mapServerRenderToLocalRecords( const renderParams = toRecord(render.params) const workspaceSnapshot = toRecord(render.workspaceSnapshot) const snapshotSku = toRecord(workspaceSnapshot.sku) - const skuId = render.workbenchSkuId ?? (typeof snapshotSku.id === 'string' ? snapshotSku.id : '') + const skuId = + render.intakeSkuItemId ?? + render.workbenchSkuId ?? + (typeof snapshotSku.id === 'string' ? snapshotSku.id : '') const skuName = typeof snapshotSku.name === 'string' && snapshotSku.name.trim() ? snapshotSku.name diff --git a/furniture-3d-system/src/app/ai-render-workbench/workbenchState.ts b/furniture-3d-system/src/app/ai-render-workbench/workbenchState.ts index af21d263..b30b5bd1 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/workbenchState.ts +++ b/furniture-3d-system/src/app/ai-render-workbench/workbenchState.ts @@ -272,6 +272,9 @@ export function normalizeSku(value: unknown, index: number): AiRenderSku { const nodeMaterials = normalizeNodeMaterials(sku.nodeMaterials) return { id: typeof sku.id === 'string' && sku.id.trim() ? sku.id : createId('sku'), + productId: typeof sku.productId === 'string' ? sku.productId : undefined, + skuItemId: typeof sku.skuItemId === 'string' ? sku.skuItemId : undefined, + barcode: typeof sku.barcode === 'string' ? sku.barcode : undefined, name: typeof sku.name === 'string' ? sku.name : getDefaultSkuName(index + 1), modelUrl: typeof sku.modelUrl === 'string' ? sku.modelUrl : '', thumbnailUrl: typeof sku.thumbnailUrl === 'string' ? sku.thumbnailUrl : null, @@ -283,6 +286,11 @@ export function normalizeSku(value: unknown, index: number): AiRenderSku { materialTextureUrl: typeof sku.materialTextureUrl === 'string' ? sku.materialTextureUrl : undefined, nodeMaterials, + uvConfig: isRecord(sku.uvConfig) ? sku.uvConfig : undefined, + modelVersionId: typeof sku.modelVersionId === 'string' ? sku.modelVersionId : null, + modelFileName: typeof sku.modelFileName === 'string' ? sku.modelFileName : null, + materialVersionId: + typeof sku.materialVersionId === 'string' ? sku.materialVersionId : null, } } diff --git a/furniture-3d-system/src/app/api/ai-render-workbench/detail-reference/route.ts b/furniture-3d-system/src/app/api/ai-render-workbench/detail-reference/route.ts index 30ab42f5..03286950 100644 --- a/furniture-3d-system/src/app/api/ai-render-workbench/detail-reference/route.ts +++ b/furniture-3d-system/src/app/api/ai-render-workbench/detail-reference/route.ts @@ -5,13 +5,17 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { assistedAssemblyAiRenderService } from '@/server/services' const createDetailReferenceSchema = z.object({ - workbenchSkuId: z.string().trim().min(1, '缺少 SKU ID'), + workbenchSkuId: z.string().trim().min(1).optional().nullable(), + intakeSkuItemId: z.string().trim().min(1).optional().nullable(), referenceImageDataUrl: z.string().min(1, '缺少特写参考图'), +}).refine((value) => Boolean(value.workbenchSkuId || value.intakeSkuItemId), { + message: '缺少条码 ID', }) const deleteDetailReferenceSchema = z.object({ storageKey: z.string().trim().min(1, '缺少特写参考图存储键'), workbenchSkuId: z.string().trim().optional().nullable(), + intakeSkuItemId: z.string().trim().optional().nullable(), }) export async function POST(request: NextRequest) { @@ -30,7 +34,21 @@ export async function POST(request: NextRequest) { } try { - const image = await assistedAssemblyAiRenderService.createWorkbenchDetailReference(parsed.data, ctx.user) + const image = parsed.data.intakeSkuItemId + ? await assistedAssemblyAiRenderService.createIntakeDetailReference( + { + intakeSkuItemId: parsed.data.intakeSkuItemId, + referenceImageDataUrl: parsed.data.referenceImageDataUrl, + }, + ctx.user, + ) + : await assistedAssemblyAiRenderService.createWorkbenchDetailReference( + { + workbenchSkuId: parsed.data.workbenchSkuId!, + referenceImageDataUrl: parsed.data.referenceImageDataUrl, + }, + ctx.user, + ) return jsonSuccess({ image }, 201, ctx.requestId) } catch (error) { if (error instanceof AppError) { diff --git a/furniture-3d-system/src/app/api/ai-render-workbench/route.ts b/furniture-3d-system/src/app/api/ai-render-workbench/route.ts index cc66def5..93b765fd 100644 --- a/furniture-3d-system/src/app/api/ai-render-workbench/route.ts +++ b/furniture-3d-system/src/app/api/ai-render-workbench/route.ts @@ -2,14 +2,17 @@ import { NextRequest } from 'next/server' import { AppError } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { aiRenderWorkbenchService } from '@/server/services' +import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service' export async function GET(request: NextRequest) { return handleRequest( request, async (ctx) => { try { - const workbench = await aiRenderWorkbenchService.list(ctx.user) - return jsonSuccess(workbench, 200, ctx.requestId) + const query = request.nextUrl.searchParams.get('query')?.trim() ?? '' + const access = await aiRenderWorkbenchService.getAccess(ctx.user) + const spus = await intakeAiAssetService.listCatalog(query, ctx.user) + return jsonSuccess({ access, spus }, 200, ctx.requestId) } catch (error) { if (error instanceof AppError) { return jsonError(error.code, error.message, error.statusCode, ctx.requestId) diff --git a/furniture-3d-system/src/app/api/assisted-assembly/ai-renders/route.ts b/furniture-3d-system/src/app/api/assisted-assembly/ai-renders/route.ts index 4c02ffca..e45f1570 100644 --- a/furniture-3d-system/src/app/api/assisted-assembly/ai-renders/route.ts +++ b/furniture-3d-system/src/app/api/assisted-assembly/ai-renders/route.ts @@ -31,6 +31,7 @@ const createAiRenderSchema = z.object({ viewName: z.string().trim().max(32).optional().nullable(), viewIndex: z.number().int().min(0).max(31).optional().nullable(), workbenchSkuId: z.string().trim().max(160).optional().nullable(), + intakeSkuItemId: z.string().trim().max(160).optional().nullable(), referenceImageDataUrl: z.string().min(1, '缺少当前 3D 预览截图'), structureReferenceImageDataUrls: z .array(z.string().min(1)) @@ -64,6 +65,15 @@ export async function GET(request: NextRequest) { request, async (ctx) => { const workbenchSkuId = request.nextUrl.searchParams.get('workbenchSkuId')?.trim() + const intakeSkuItemId = request.nextUrl.searchParams.get('intakeSkuItemId')?.trim() + if (intakeSkuItemId) { + const renders = await assistedAssemblyAiRenderService.listForIntakeSku( + intakeSkuItemId, + ctx.user, + { limit: readLimit(request) }, + ) + return jsonSuccess({ renders }, 200, ctx.requestId) + } if (workbenchSkuId) { const renders = await assistedAssemblyAiRenderService.listForWorkbenchSku( workbenchSkuId, @@ -97,7 +107,7 @@ export async function POST(request: NextRequest) { ) } - if (!parsed.data.workbenchSkuId && !canOperateDesignWorkspace(ctx.user)) { + if (!parsed.data.workbenchSkuId && !parsed.data.intakeSkuItemId && !canOperateDesignWorkspace(ctx.user)) { return jsonError( ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有辅助装配 AI 生图权限', diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/material/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/material/route.ts new file mode 100644 index 00000000..c0f07590 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/material/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service' + +const materialSchema = z.object({ + skuDatabaseId: z.string().trim().min(1).max(180), + materialColor: z.string().trim().max(32).optional().nullable(), + materialTextureUrl: z.string().trim().max(4000).optional().nullable(), + nodeMaterials: z.unknown().optional(), + uvConfig: z.unknown().optional(), +}) + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const parsed = materialSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + parsed.error.issues[0]?.message ?? '材质参数不正确', + 400, + ctx.requestId, + ) + } + try { + const sku = await intakeAiAssetService.saveMaterial({ + ...parsed.data, + productId: id, + user: ctx.user, + }) + return jsonSuccess({ sku }, 201, 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/api/intake/non-integrated/[id]/ai-render/model/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/model/route.ts new file mode 100644 index 00000000..3ea361a4 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/model/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + try { + const { id } = await params + const formData = await request.formData() + const file = formData.get('file') + const skuItemId = formData.get('skuItemId') + if (!(file instanceof File)) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '请选择模型文件', + 400, + ctx.requestId, + ) + } + if (typeof skuItemId !== 'string' || !skuItemId.trim()) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '缺少条码记录', + 400, + ctx.requestId, + ) + } + const sku = await intakeAiAssetService.uploadModel({ + productId: id, + skuItemId: skuItemId.trim(), + file, + user: ctx.user, + }) + return jsonSuccess({ sku }, 201, 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/api/intake/non-integrated/[id]/ai-render/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/route.ts new file mode 100644 index 00000000..a6d3de5d --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/ai-render/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server' +import { AppError } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + try { + const { id } = await params + const product = await intakeAiAssetService.getProductWorkspace(id, ctx.user) + return jsonSuccess({ product }, 200, 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/intake/barcode-print/BarcodePrintHeader.test.tsx b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintHeader.test.tsx new file mode 100644 index 00000000..d73bf1b8 --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintHeader.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { BarcodePrintHeader } from "./BarcodePrintHeader"; +import { EMPTY_FILTERS } from "./barcodePrintFiltering"; + +function renderHeader(canManageImports: boolean) { + return render( + , + ); +} + +describe("BarcodePrintHeader", () => { + it("hides all import controls for users without import permission", () => { + renderHeader(false); + + expect(screen.queryByRole("button", { name: "条码导入" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "条码导入模板下载" }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "品牌货号导入" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "品牌货号导入模板下载" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "查询" })).toBeInTheDocument(); + }); + + it("shows all import controls for designers and administrators", () => { + renderHeader(true); + + expect(screen.getByRole("button", { name: "条码导入" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "条码导入模板下载" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "品牌货号导入" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "品牌货号导入模板下载" }), + ).toBeInTheDocument(); + }); +}); diff --git a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintHeader.tsx b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintHeader.tsx index 7395b5ce..9c10ce0a 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintHeader.tsx +++ b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintHeader.tsx @@ -21,6 +21,7 @@ export function BarcodePrintHeader({ productCategoryOptions, salesCategoryOptions, statusMessage, + canManageImports, onBarcodeImport, actionDisabled = false, onBrandSkuImport, @@ -33,6 +34,7 @@ export function BarcodePrintHeader({ productCategoryOptions: string[]; salesCategoryOptions: string[]; statusMessage: string; + canManageImports: boolean; actionDisabled?: boolean; onBarcodeImport: () => void; onBrandSkuImport: () => void; @@ -46,26 +48,32 @@ export function BarcodePrintHeader({ }) { return (
-
- - - onDownloadTemplate("barcode")}> - - - - onDownloadTemplate("brandSku")}> - -
+ {canManageImports ? ( +
+ + + onDownloadTemplate("barcode")}> + + + + onDownloadTemplate("brandSku")}> + +
+ ) : null} -
+
void; onBarcodePrint: (row: BarcodePrintRow) => void; onCertificatePrint: (row: BarcodePrintRow) => void; + canManageImports: boolean; onDeleteImportedData: (importRecordIds: string[] | undefined) => void; }) { return ( @@ -213,7 +215,7 @@ export function BarcodePrintTable({ >
+
+ + + + ) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/NonIntegratedAiRenderWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/NonIntegratedAiRenderWorkspace.tsx new file mode 100644 index 00000000..17401119 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/NonIntegratedAiRenderWorkspace.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useRef } from 'react' +import { ArrowUpRight, Upload } from 'lucide-react' +import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' +import { IntakeAiRenderResultPanel } from './IntakeAiRenderResultPanel' +import { useIntakeAiRenderWorkspace } from './useIntakeAiRenderWorkspace' + +export function NonIntegratedAiRenderWorkspace(props: { + productId: string | null + selectedBarcode: string + isReadOnly: boolean + onNotify: (message: string, type: 'success' | 'error') => void +}) { + const inputRef = useRef(null) + const state = useIntakeAiRenderWorkspace({ + productId: props.productId, + selectedBarcode: props.selectedBarcode, + onNotify: props.onNotify, + }) + + const openWorkbench = () => { + if (!props.productId || !state.selectedSku) return + const returnTo = `${window.location.pathname}${window.location.search}` + const params = new URLSearchParams({ + productId: props.productId, + skuId: state.selectedSku.id, + returnTo, + }) + window.location.href = `/ai-render-workbench?${params.toString()}` + } + + return ( +
+ + +
+
+
+

+ {state.selectedSku?.barcode || state.selectedSku?.name || '未选择条码'} +

+

+ {state.selectedSku?.modelFileName || (state.loading ? '加载中' : '3D 模型')} +

+
+ {!props.isReadOnly ? ( + <> + { + const file = event.target.files?.[0] + if (file) void state.uploadModel(file) + event.currentTarget.value = '' + }} + /> + + + ) : null} + +
+ +
+
+ ) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/intakeAiRenderWorkspaceTypes.test.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/intakeAiRenderWorkspaceTypes.test.ts new file mode 100644 index 00000000..0d1e9471 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/intakeAiRenderWorkspaceTypes.test.ts @@ -0,0 +1,47 @@ +import { getIntakeAiRenderCategory } from './intakeAiRenderWorkspaceTypes' + +function createRecord(overrides: Record) { + return { + id: 'render-1', + workbenchSpuId: null, + workbenchSkuId: null, + intakeSkuItemId: 'sku-1', + outputMode: null, + storageClass: 'PERMANENT', + prompt: '', + negativePrompt: null, + params: {}, + referenceImageUrl: null, + resultImages: [], + workspaceSnapshot: {}, + assetSnapshot: {}, + externalRequestId: null, + attempts: null, + status: 'SUCCEEDED', + errorMessage: null, + durationMs: 10, + restoreUrl: '', + creatorName: null, + createdAt: '2026-07-29T00:00:00.000Z', + updatedAt: '2026-07-29T00:00:00.000Z', + ...overrides, + } as Parameters[0] +} + +describe('getIntakeAiRenderCategory', () => { + it('uses the persisted output mode for permanent records', () => { + expect( + getIntakeAiRenderCategory(createRecord({ outputMode: 'WHITE_BACKGROUND' })), + ).toBe('white-background') + expect(getIntakeAiRenderCategory(createRecord({ outputMode: 'EIGHT_VIEW' }))).toBe( + 'eight-view', + ) + expect(getIntakeAiRenderCategory(createRecord({ outputMode: 'SCENE' }))).toBe('scene') + }) + + it('falls back to params for migrated records', () => { + expect( + getIntakeAiRenderCategory(createRecord({ params: { mode: 'eight-view' } })), + ).toBe('eight-view') + }) +}) diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/intakeAiRenderWorkspaceTypes.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/intakeAiRenderWorkspaceTypes.ts new file mode 100644 index 00000000..7fdec84d --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/intakeAiRenderWorkspaceTypes.ts @@ -0,0 +1,27 @@ +import type { + AiRenderWorkbenchSkuDTO, + AiRenderWorkbenchSpuDTO, + AssistedAssemblyAiRenderDTO, +} from '@/server/services' + +export type IntakeAiRenderProduct = AiRenderWorkbenchSpuDTO +export type IntakeAiRenderSku = AiRenderWorkbenchSkuDTO +export type IntakeAiRenderRecord = AssistedAssemblyAiRenderDTO + +export type IntakeAiRenderCategory = 'white-background' | 'eight-view' | 'scene' + +export function getIntakeAiRenderCategory( + record: IntakeAiRenderRecord, +): IntakeAiRenderCategory { + if (record.outputMode === 'WHITE_BACKGROUND') return 'white-background' + if (record.outputMode === 'EIGHT_VIEW') return 'eight-view' + if (record.outputMode === 'SCENE') return 'scene' + + const params = + record.params && typeof record.params === 'object' && !Array.isArray(record.params) + ? record.params + : {} + if (params.mode === 'white-background') return 'white-background' + if (params.mode === 'eight-view') return 'eight-view' + return 'scene' +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/useIntakeAiRenderWorkspace.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/useIntakeAiRenderWorkspace.ts new file mode 100644 index 00000000..a372712a --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-render/useIntakeAiRenderWorkspace.ts @@ -0,0 +1,122 @@ +'use client' + +import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react' +import { + fetchIntakeAiRenderWorkspace, + listAiRenderWorkbenchSkuRenders, + uploadIntakeAiRenderModel, +} from '@/lib/open-product/platform-api' +import type { + IntakeAiRenderProduct, + IntakeAiRenderRecord, +} from './intakeAiRenderWorkspaceTypes' + +export function useIntakeAiRenderWorkspace(input: { + productId: string | null + selectedBarcode: string + onNotify: (message: string, type: 'success' | 'error') => void +}) { + const { productId, selectedBarcode, onNotify } = input + const [product, setProduct] = useState(null) + const [recordState, setRecordState] = useState<{ + skuId: string + records: IntakeAiRenderRecord[] + }>({ skuId: '', records: [] }) + const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) + const notifyFromEffect = useEffectEvent(onNotify) + const currentProduct = product?.id === productId ? product : null + const selectedSku = useMemo( + () => + currentProduct?.skus.find((sku) => sku.barcode === selectedBarcode) ?? + currentProduct?.skus[0] ?? + null, + [currentProduct, selectedBarcode], + ) + const records = recordState.skuId === selectedSku?.id ? recordState.records : [] + + const loadProduct = useCallback(async () => { + if (!productId) return null + const nextProduct = await fetchIntakeAiRenderWorkspace(productId) + setProduct(nextProduct) + return nextProduct + }, [productId]) + + useEffect(() => { + if (!productId) { + return + } + let active = true + void (async () => { + await Promise.resolve() + if (active) setLoading(true) + try { + await loadProduct() + } catch (error) { + if (active) { + notifyFromEffect(error instanceof Error ? error.message : 'AI 出图资料加载失败', 'error') + } + } finally { + if (active) setLoading(false) + } + })() + return () => { + active = false + } + }, [loadProduct, productId]) + + const refreshRecords = useCallback(async (skuId: string) => { + const nextRecords = await listAiRenderWorkbenchSkuRenders(skuId, 60) + setRecordState({ skuId, records: nextRecords }) + }, []) + + useEffect(() => { + if (!selectedSku?.id) { + return + } + let active = true + void (async () => { + try { + await refreshRecords(selectedSku.id) + } catch (error) { + if (active) { + notifyFromEffect(error instanceof Error ? error.message : '生成图片加载失败', 'error') + } + } + })() + return () => { + active = false + } + }, [refreshRecords, selectedSku?.id]) + + const uploadModel = useCallback( + async (file: File) => { + if (!productId || !selectedSku?.skuItemId) return + setUploading(true) + try { + await uploadIntakeAiRenderModel({ + productId, + skuItemId: selectedSku.skuItemId, + file, + }) + await loadProduct() + onNotify('共享模型已更新', 'success') + } catch (error) { + onNotify(error instanceof Error ? error.message : '模型上传失败', 'error') + } finally { + setUploading(false) + } + }, + [loadProduct, onNotify, productId, selectedSku], + ) + + return { + product: currentProduct, + selectedSku, + records, + loading, + uploading, + refreshRecords, + uploadModel, + } +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoHeaderActions.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoHeaderActions.tsx index 216c124d..17a96c21 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoHeaderActions.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoHeaderActions.tsx @@ -61,6 +61,7 @@ export function ProductPhotoHeaderActions({ const modeOptions: { key: ProductPhotoWorkspaceMode; label: string }[] = [ { key: "source", label: "实拍资料" }, { key: "ai-white-background", label: "AI 白底图" }, + { key: "ai-render", label: "AI 出图" }, ]; return ( @@ -88,7 +89,7 @@ export function ProductPhotoHeaderActions({
- {isReadOnly || activeMode === "ai-white-background" ? null : ( + {isReadOnly || activeMode !== "source" ? null : ( <> ) : null} - {isReadOnly || activeMode === "ai-white-background" ? null : ( + {isReadOnly || activeMode !== "source" ? null : ( {isActive ? ( -
+
{entry.barcodeEntries.map((barcodeEntry) => { const isBarcodeActive = activeBarcodeEntry?.barcode === barcodeEntry.barcode; @@ -138,13 +140,13 @@ export function ProductPhotoWorkspace({ onClick={() => onSelectBarcode(barcodeEntry.barcode) } - className={`rounded-[7px] border px-2.5 py-2 text-left transition ${ + className={`w-full min-w-0 rounded-[7px] border px-2.5 py-2 text-left transition ${ isBarcodeActive ? "border-[#47646b] bg-white shadow-[0_6px_18px_rgba(45,51,53,0.08)] ring-1 ring-[#47646b]/16" : "border-[#adb3b5]/16 bg-white/82 hover:bg-white" }`} > -
+

{barcodeEntry.barcode} @@ -185,12 +187,12 @@ export function ProductPhotoWorkspace({

- {activeMode === "ai-white-background" ? ( + {activeMode === "ai-white-background" || activeMode === "ai-render" ? (
- {aiWhiteBackgroundContent} + {activeMode === "ai-white-background" ? aiWhiteBackgroundContent : aiRenderContent}
) : ( <> diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceHelpers.ts index af2866ac..5c3d6162 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceHelpers.ts @@ -52,7 +52,7 @@ export type ProductPhotoFileGroup = | "competitor-sample" | "competitor-disassembly"; export type ProductPhotoGroup = "nine-view" | ProductPhotoFileGroup; -export type ProductPhotoWorkspaceMode = "source" | "ai-white-background"; +export type ProductPhotoWorkspaceMode = "source" | "ai-white-background" | "ai-render"; export type ProductPhotoNineViewSlot = | "perspective" | "top" diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceRenderers.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceRenderers.tsx index 51968bd7..7220a468 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceRenderers.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/documentWorkspaceRenderers.tsx @@ -28,9 +28,10 @@ type ReviewSheetWorkspaceProps = ComponentProps; type ProductPhotoWorkspaceRendererProps = Omit< ProductPhotoWorkspaceProps, - "aiWhiteBackgroundContent" | "previewContent" + "aiWhiteBackgroundContent" | "aiRenderContent" | "previewContent" > & { renderAiWhiteBackgroundContent: () => ReactNode; + renderAiRenderContent: () => ReactNode; renderPreviewContent: () => ReactNode; }; @@ -68,6 +69,7 @@ export function buildDocumentWorkspaceRenderers({ }: DocumentWorkspaceRendererOptions) { const { renderAiWhiteBackgroundContent, + renderAiRenderContent, renderPreviewContent, ...productPhotoProps } = productPhoto; @@ -85,6 +87,7 @@ export function buildDocumentWorkspaceRenderers({ ); diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index 575cda6a..fc41d0e8 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -71,12 +71,15 @@ interface ApiEnvelope { export type AssetReviewAction = 'SUBMIT' | 'APPROVE' | 'REJECT' async function requestPlatform(input: RequestInfo, init?: RequestInit): Promise { + const isFormDataBody = typeof FormData !== 'undefined' && init?.body instanceof FormData const response = await fetch(input, { ...init, - headers: { - 'Content-Type': 'application/json', - ...(init?.headers ?? {}), - }, + headers: isFormDataBody + ? init?.headers + : { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, }) const payload = (await response.json().catch(() => null)) as ApiEnvelope | null @@ -943,6 +946,7 @@ export interface CreateAssistedAssemblyAiRenderPayload { viewName?: string | null viewIndex?: number | null workbenchSkuId?: string | null + intakeSkuItemId?: string | null referenceImageDataUrl: string structureReferenceImageDataUrls?: string[] | null materialPartAnnotations?: Array<{ @@ -972,6 +976,54 @@ export async function fetchAiRenderWorkbench(): Promise { return requestPlatform('/api/ai-render-workbench') } +export async function fetchIntakeAiRenderWorkspace( + productId: string, +): Promise { + const data = await requestPlatform<{ product: AiRenderWorkbenchSpuDTO }>( + `/api/intake/non-integrated/${encodeURIComponent(productId)}/ai-render`, + ) + return data.product +} + +export async function uploadIntakeAiRenderModel(input: { + productId: string + skuItemId: string + file: File +}): Promise { + const formData = new FormData() + formData.append('skuItemId', input.skuItemId) + formData.append('file', input.file) + const data = await requestPlatform<{ sku: AiRenderWorkbenchSkuDTO }>( + `/api/intake/non-integrated/${encodeURIComponent(input.productId)}/ai-render/model`, + { method: 'POST', body: formData }, + ) + return data.sku +} + +export async function saveIntakeAiRenderMaterial(input: { + productId: string + skuDatabaseId: string + materialColor?: string | null + materialTextureUrl?: string | null + nodeMaterials?: unknown + uvConfig?: unknown +}): Promise { + const data = await requestPlatform<{ sku: AiRenderWorkbenchSkuDTO }>( + `/api/intake/non-integrated/${encodeURIComponent(input.productId)}/ai-render/material`, + { + method: 'POST', + body: JSON.stringify({ + skuDatabaseId: input.skuDatabaseId, + materialColor: input.materialColor, + materialTextureUrl: input.materialTextureUrl, + nodeMaterials: input.nodeMaterials, + uvConfig: input.uvConfig, + }), + }, + ) + return data.sku +} + export async function fetchInstallationDemoWorkbenchCatalog(): Promise { return requestPlatform('/api/installation-demo-workbench/catalog') } @@ -1012,7 +1064,7 @@ export async function createInstallationDemoShareLink( } export async function createAiRenderWorkbenchDetailReference(input: { - workbenchSkuId: string + intakeSkuItemId: string referenceImageDataUrl: string }): Promise { const data = await requestPlatform<{ image: AiRenderWorkbenchTemporaryImageDTO }>( @@ -1027,7 +1079,7 @@ export async function createAiRenderWorkbenchDetailReference(input: { export async function deleteAiRenderWorkbenchDetailReference(input: { storageKey: string - workbenchSkuId?: string | null + intakeSkuItemId?: string | null }): Promise { await requestPlatform<{ ok: boolean }>('/api/ai-render-workbench/detail-reference', { method: 'DELETE', @@ -1102,7 +1154,7 @@ export async function listAiRenderWorkbenchSkuRenders( limit = 24, ): Promise { const searchParams = new URLSearchParams({ - workbenchSkuId: skuId, + intakeSkuItemId: skuId, limit: String(limit), }) const data = await requestPlatform<{ renders: AssistedAssemblyAiRenderDTO[] }>( diff --git a/furniture-3d-system/src/lib/role-scope.ts b/furniture-3d-system/src/lib/role-scope.ts index 91fbd9db..5399f32a 100644 --- a/furniture-3d-system/src/lib/role-scope.ts +++ b/furniture-3d-system/src/lib/role-scope.ts @@ -78,6 +78,10 @@ export function isDesigner(user: ScopedRoleUser): boolean { return hasBusinessRole(user, 'DESIGN') } +export function canManageBarcodePrintImports(user: ScopedRoleUser): boolean { + return isAdmin(user) || isDesigner(user) +} + function normalizeDepartmentPath(value: unknown) { return typeof value === 'string' ? value.trim().replace(/\s+/g, '') : '' } diff --git a/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts b/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts new file mode 100644 index 00000000..b3f676e8 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts @@ -0,0 +1,250 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' + +const skuAiProfileInclude = { + currentModelVersion: { + include: { + asset: true, + }, + }, + currentMaterialVersion: true, +} satisfies Prisma.IntakeSkuAiProfileInclude + +const catalogProductInclude = { + profile: true, + skuItems: { + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: { + aiProfile: { + include: skuAiProfileInclude, + }, + }, + }, +} satisfies Prisma.IntakeNonIntegratedProductInclude + +export type IntakeAiCatalogProductRecord = Prisma.IntakeNonIntegratedProductGetPayload<{ + include: typeof catalogProductInclude +}> + +export type IntakeAiSkuRecord = IntakeAiCatalogProductRecord['skuItems'][number] + +export interface CreateModelVersionInput { + id: string + assetId: string + versionNo: number + originalFileName: string + mimeType: string + fileSize: number + checksumSha256: string + sourceStorageKey: string + sourceUrl: string + previewStorageKey: string | null + previewUrl: string | null + createdBy: string +} + +export class IntakeAiAssetRepository { + findCatalogProducts(query: string, take = 200) { + const normalizedQuery = query.trim() + return prisma.intakeNonIntegratedProduct.findMany({ + where: { + deletedAt: null, + ...(normalizedQuery + ? { + OR: [ + { spu: { contains: normalizedQuery, mode: 'insensitive' as const } }, + { spuBase: { contains: normalizedQuery, mode: 'insensitive' as const } }, + { + profile: { + productName: { contains: normalizedQuery, mode: 'insensitive' as const }, + }, + }, + { + skuItems: { + some: { + OR: [ + { barcode: { contains: normalizedQuery, mode: 'insensitive' as const } }, + { skuCode: { contains: normalizedQuery, mode: 'insensitive' as const } }, + { model: { contains: normalizedQuery, mode: 'insensitive' as const } }, + ], + }, + }, + }, + ], + } + : {}), + }, + include: catalogProductInclude, + orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], + take, + }) + } + + findProductById(productId: string) { + return prisma.intakeNonIntegratedProduct.findFirst({ + where: { id: productId, deletedAt: null }, + include: catalogProductInclude, + }) + } + + findSkuByDatabaseId(skuItemId: string) { + return prisma.intakeNonIntegratedSkuItem.findUnique({ + where: { id: skuItemId }, + include: { + product: { include: { profile: true } }, + aiProfile: { include: skuAiProfileInclude }, + }, + }) + } + + findSkuByProductKey(productId: string, skuItemId: string) { + return prisma.intakeNonIntegratedSkuItem.findUnique({ + where: { + productId_skuItemId: { productId, skuItemId }, + }, + include: { + product: { include: { profile: true } }, + aiProfile: { include: skuAiProfileInclude }, + }, + }) + } + + async getOrCreateModelAsset(input: { + productId: string + modelKey: string + displayName: string + createdBy: string + }) { + return prisma.intakeProductModelAsset.upsert({ + where: { + productId_modelKey: { + productId: input.productId, + modelKey: input.modelKey, + }, + }, + update: { + displayName: input.displayName, + }, + create: input, + }) + } + + async getNextModelVersionNo(assetId: string) { + const latest = await prisma.intakeProductModelVersion.findFirst({ + where: { assetId }, + orderBy: { versionNo: 'desc' }, + select: { versionNo: true }, + }) + return (latest?.versionNo ?? 0) + 1 + } + + async createModelVersionAndBind(input: { + version: CreateModelVersionInput + selectedSkuDatabaseId: string + productId: string + modelValue: string + previewUrl: string + createdBy: string + initializeModelGroup: boolean + }) { + return prisma.$transaction(async (tx) => { + const version = await tx.intakeProductModelVersion.create({ + data: input.version, + include: { asset: true }, + }) + + const candidates = input.initializeModelGroup + ? await tx.intakeNonIntegratedSkuItem.findMany({ + where: { + productId: input.productId, + model: { equals: input.modelValue, mode: 'insensitive' }, + }, + select: { + id: true, + model3dDataUrl: true, + aiProfile: { select: { id: true, currentModelVersionId: true } }, + }, + }) + : [] + + const targetIds = new Set([input.selectedSkuDatabaseId]) + candidates.forEach((candidate) => { + if (!candidate.aiProfile?.currentModelVersionId) { + targetIds.add(candidate.id) + } + }) + + for (const targetId of targetIds) { + await tx.intakeSkuAiProfile.upsert({ + where: { skuItemId: targetId }, + update: { + currentModelVersionId: version.id, + updatedBy: input.createdBy, + }, + create: { + skuItemId: targetId, + currentModelVersionId: version.id, + createdBy: input.createdBy, + updatedBy: input.createdBy, + }, + }) + + await tx.intakeNonIntegratedSkuItem.update({ + where: { id: targetId }, + data: { model3dDataUrl: input.previewUrl }, + }) + } + + return version + }) + } + + async createMaterialVersion(input: { + skuDatabaseId: string + createdBy: string + materialColor: string + textureStorageKey: string | null + textureUrl: string | null + nodeMaterials: Prisma.InputJsonValue | typeof Prisma.JsonNull + uvConfig: Prisma.InputJsonValue | typeof Prisma.JsonNull + }) { + return prisma.$transaction(async (tx) => { + const profile = await tx.intakeSkuAiProfile.upsert({ + where: { skuItemId: input.skuDatabaseId }, + update: { updatedBy: input.createdBy }, + create: { + skuItemId: input.skuDatabaseId, + createdBy: input.createdBy, + updatedBy: input.createdBy, + }, + }) + const latest = await tx.intakeSkuAiMaterialVersion.findFirst({ + where: { profileId: profile.id }, + orderBy: { versionNo: 'desc' }, + select: { versionNo: true }, + }) + const materialVersion = await tx.intakeSkuAiMaterialVersion.create({ + data: { + profileId: profile.id, + versionNo: (latest?.versionNo ?? 0) + 1, + materialColor: input.materialColor, + textureStorageKey: input.textureStorageKey, + textureUrl: input.textureUrl, + nodeMaterials: input.nodeMaterials, + uvConfig: input.uvConfig, + createdBy: input.createdBy, + }, + }) + await tx.intakeSkuAiProfile.update({ + where: { id: profile.id }, + data: { + currentMaterialVersionId: materialVersion.id, + updatedBy: input.createdBy, + }, + }) + return materialVersion + }) + } +} + +export const intakeAiAssetRepository = new IntakeAiAssetRepository() diff --git a/furniture-3d-system/src/server/services/__tests__/assisted-assembly-ai-render.permanent-storage.test.ts b/furniture-3d-system/src/server/services/__tests__/assisted-assembly-ai-render.permanent-storage.test.ts new file mode 100644 index 00000000..812a1ab7 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/assisted-assembly-ai-render.permanent-storage.test.ts @@ -0,0 +1,33 @@ +import { buildPermanentIntakeRenderStorageKey } from '../assisted-assembly-ai-render.permanent-storage' + +describe('permanent intake AI render storage', () => { + it('keeps barcode records and categories in separate product-development folders', () => { + expect( + buildPermanentIntakeRenderStorageKey({ + skuItemId: 'sku-a', + renderId: 'render-1', + mode: 'eight-view', + kind: 'results', + extension: 'webp', + index: 2, + }), + ).toBe( + 'product-development/ai-renders/sku-a/eight-view/render-1/results/image-3.webp', + ) + }) + + it('stores references beside their scene render without using the temporary prefix', () => { + const key = buildPermanentIntakeRenderStorageKey({ + skuItemId: 'sku-b', + renderId: 'render-2', + mode: 'scene', + kind: 'references', + extension: 'png', + }) + + expect(key).toBe( + 'product-development/ai-renders/sku-b/scene/render-2/references/image.png', + ) + expect(key).not.toContain('ai-render-workbench/temp') + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/barcode-print.service.test.ts b/furniture-3d-system/src/server/services/__tests__/barcode-print.service.test.ts index 515a8e82..f36603d7 100644 --- a/furniture-3d-system/src/server/services/__tests__/barcode-print.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/barcode-print.service.test.ts @@ -1,4 +1,5 @@ import type { AuthUser } from '@/server/core/types' +import { ErrorCode } from '@/server/core/errors' var mockProducts: unknown[] = [] var mockImportRecords: unknown[] = [] @@ -143,12 +144,10 @@ describe('barcodePrintService', () => { expect(mockFindMany).not.toHaveBeenCalled() }) - it('供应商导入时按当前供应商编码写入正式导入表', async () => { + it('设计师和管理员可以导入条码数据', async () => { await barcodePrintService.saveImportRecords( buildUser({ - businessRoles: ['SUPPLIER'], - supplierCode: 'N1280', - supplierName: '临沂美诗家居', + businessRoles: ['DESIGN'], }), [ { @@ -176,43 +175,59 @@ describe('barcodePrintService', () => { }, }, create: expect.objectContaining({ - supplierCode: 'N1280', - supplierName: '临沂美诗家居', createdBy: 'user-1', }), }), ) - }) - it('供应商不能导入自己不可见的货号', async () => { - await expect( - barcodePrintService.saveImportRecords( - buildUser({ - businessRoles: ['SUPPLIER'], - supplierCode: 'N1280', - }), - [ - { - kind: 'barcode', - skuCode: 'OTHER001', - updates: { - skuCode: 'OTHER001', - barcode: '690000000009', - }, + await barcodePrintService.saveImportRecords( + buildUser({ role: 'ADMIN' }), + [ + { + kind: 'brandSku', + skuCode: 'CS000201A', + updates: { + skuCode: 'CS000201A', + brandSkuCode: 'BRAND-001', }, - ], - ), - ).rejects.toMatchObject({ - message: expect.stringContaining('不属于当前供应商'), - }) + }, + ], + ) + + expect(mockUpsert).toHaveBeenCalledTimes(2) }) - it('供应商删除导入数据时自动带供应商编码条件', async () => { + const nonImportManagers = [ + ['供应商', buildUser({ businessRoles: ['SUPPLIER'], supplierCode: 'N1280' })], + ['主管', buildUser({ role: 'SUPERVISOR' })], + ['采购', buildUser({ businessRoles: ['PURCHASING'] })], + ] as const + + it.each(nonImportManagers)('%s不能导入条码数据', async (_label, user) => { + await expect( + barcodePrintService.saveImportRecords(user, [ + { + kind: 'barcode', + skuCode: 'CS000201A', + updates: { skuCode: 'CS000201A', barcode: '690000000009' }, + }, + ]), + ).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED }) + + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it.each(nonImportManagers)('%s不能删除导入数据', async (_label, user) => { + await expect( + barcodePrintService.deleteImportRecords(user, ['record-1']), + ).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED }) + + expect(mockDeleteMany).not.toHaveBeenCalled() + }) + + it('设计师可以删除导入数据', async () => { await barcodePrintService.deleteImportRecords( - buildUser({ - businessRoles: ['SUPPLIER'], - supplierCode: 'N1280', - }), + buildUser({ businessRoles: ['DESIGN'] }), ['record-1'], ) @@ -221,7 +236,6 @@ describe('barcodePrintService', () => { id: { in: ['record-1'], }, - supplierCode: 'N1280', }, }) }) diff --git a/furniture-3d-system/src/server/services/ai-render-workbench.service.ts b/furniture-3d-system/src/server/services/ai-render-workbench.service.ts index 4d18dad6..9edb14a9 100644 --- a/furniture-3d-system/src/server/services/ai-render-workbench.service.ts +++ b/furniture-3d-system/src/server/services/ai-render-workbench.service.ts @@ -18,6 +18,9 @@ const IMPORT_RECORD_LIMIT = 200 export interface AiRenderWorkbenchSkuDTO { id: string + productId?: string + skuItemId?: string + barcode?: string name: string modelUrl: string thumbnailUrl: string | null @@ -25,6 +28,10 @@ export interface AiRenderWorkbenchSkuDTO { materialColor: string materialTextureUrl?: string nodeMaterials?: Record + uvConfig?: Record + modelVersionId?: string | null + modelFileName?: string | null + materialVersionId?: string | null } export interface AiRenderWorkbenchSpuDTO { @@ -401,13 +408,13 @@ export class AiRenderWorkbenchService { createdBy: user.id, updatedBy: user.id, } - const sku = skuId - ? await tx.aiRenderWorkbenchSku.upsert({ + await (skuId + ? tx.aiRenderWorkbenchSku.upsert({ where: { id: skuId }, update: skuData, create: { id: skuId, ...skuData }, }) - : await tx.aiRenderWorkbenchSku.create({ data: skuData }) + : tx.aiRenderWorkbenchSku.create({ data: skuData })) } } diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts index 0a7bb115..4f634746 100644 --- a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts @@ -1,5 +1,9 @@ import type { Prisma } from '@prisma/client' -import { AssistedAssemblyAiRenderStatus } from '@prisma/client' +import { + AssistedAssemblyAiRenderStatus, + IntakeAiRenderOutputMode, + IntakeAiRenderStorageClass, +} from '@prisma/client' import { nanoid } from 'nanoid' import { prisma } from '@/lib/prisma' import { @@ -8,6 +12,7 @@ import { } from '@/lib/storage' import type { AuthUser } from '@/server/core/types' import { aiRenderWorkbenchService } from './ai-render-workbench.service' +import { intakeAiAssetService } from './intake-ai-asset.service' import { cleanWorkbenchResultBackground, shouldCleanWorkbenchResultBackground, @@ -30,6 +35,10 @@ import { uploadTemporaryWorkbenchReferenceImage, uploadTemporaryWorkbenchResultImage, } from './assisted-assembly-ai-render.temp-storage' +import { + persistPermanentIntakeResultImages, + uploadPermanentIntakeReferenceImage, +} from './assisted-assembly-ai-render.permanent-storage' import { decodeReferenceImageDataUrl, decodeReferenceImageDataUrls, @@ -63,6 +72,7 @@ export interface CreateAssistedAssemblyAiRenderInput { viewName?: string | null viewIndex?: number | null workbenchSkuId?: string | null + intakeSkuItemId?: string | null referenceImageDataUrl: string structureReferenceImageDataUrls?: string[] | null materialPartAnnotations?: Array<{ @@ -73,6 +83,12 @@ export interface CreateAssistedAssemblyAiRenderInput { assetSnapshot: unknown } +function resolveOutputMode(mode: string): IntakeAiRenderOutputMode { + if (mode === 'white-background') return IntakeAiRenderOutputMode.WHITE_BACKGROUND + if (mode === 'eight-view') return IntakeAiRenderOutputMode.EIGHT_VIEW + return IntakeAiRenderOutputMode.SCENE +} + function isPrivateIpv4Hostname(hostname: string): boolean { const parts = hostname.split('.').map((part) => Number(part)) if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { @@ -303,16 +319,22 @@ export async function createAssistedAssemblyAiRender( const batchId = normalizeOptionalText(input.batchId, '图集批次', 160) const viewName = normalizeOptionalText(input.viewName, '视图名称', 32) const viewIndex = normalizeOptionalViewIndex(input.viewIndex) - const workbenchSkuId = trimToNull(input.workbenchSkuId) - const workbenchSku = workbenchSkuId - ? await aiRenderWorkbenchService.getSkuForRender(workbenchSkuId, user) + const intakeSkuItemId = trimToNull(input.intakeSkuItemId) + const intakeSku = intakeSkuItemId + ? await intakeAiAssetService.getSkuForRender(intakeSkuItemId, user) : null - imageSize = normalizeWorkbenchRequestImageSize(imageSize, Boolean(workbenchSku)) + const legacyWorkbenchSkuId = intakeSku ? null : trimToNull(input.workbenchSkuId) + const workbenchSku = legacyWorkbenchSkuId + ? await aiRenderWorkbenchService.getSkuForRender(legacyWorkbenchSkuId, user) + : null + const isCatalogRender = Boolean(intakeSku || workbenchSku) + const renderId = nanoid(24) + imageSize = normalizeWorkbenchRequestImageSize(imageSize, isCatalogRender) const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl) - const structureReferenceImages = workbenchSku + const structureReferenceImages = isCatalogRender ? decodeReferenceImageDataUrls(input.structureReferenceImageDataUrls) : [] - const materialPartAnnotations = workbenchSku + const materialPartAnnotations = isCatalogRender ? normalizeMaterialPartAnnotations(input.materialPartAnnotations) : [] const materialEntries = extractEnhancedMaterialEntriesFromSnapshot(input.assetSnapshot) @@ -355,6 +377,7 @@ export async function createAssistedAssemblyAiRender( }) const buildBaseCreateData = (referenceImageUrl: string) => ({ + id: renderId, prompt, negativePrompt, params: buildCurrentParams(), @@ -363,13 +386,32 @@ export async function createAssistedAssemblyAiRender( createdBy, workbenchSpuId: workbenchSku?.spuId ?? null, workbenchSkuId: workbenchSku?.id ?? null, + intakeSkuItemId: intakeSku?.id ?? null, + modelVersionId: intakeSku?.modelVersionId ?? null, + materialVersionId: intakeSku?.materialVersionId ?? null, + outputMode: intakeSku ? resolveOutputMode(mode) : null, + batchId, + viewName, + viewIndex, + storageClass: intakeSku + ? IntakeAiRenderStorageClass.PERMANENT + : IntakeAiRenderStorageClass.TEMPORARY, workspaceSnapshot: ensureJsonObject(input.workspaceSnapshot, '缺少辅助装配工作台快照'), assetSnapshot: ensureJsonObject(input.assetSnapshot, '缺少辅助装配物料快照'), status: AssistedAssemblyAiRenderStatus.PENDING, }) try { - referenceUpload = workbenchSku + referenceUpload = intakeSku + ? await uploadPermanentIntakeReferenceImage({ + buffer: referenceImage.buffer, + contentType: referenceImage.contentType, + extension: referenceImage.extension, + skuItemId: intakeSku.id, + renderId, + mode, + }) + : workbenchSku ? await uploadTemporaryWorkbenchReferenceImage({ buffer: referenceImage.buffer, contentType: referenceImage.contentType, @@ -458,7 +500,14 @@ export async function createAssistedAssemblyAiRender( disabledMessage: '辅助装配 AI 生图服务未启用或未配置', }) - const resultImages = workbenchSku + const resultImages = intakeSku + ? await persistPermanentIntakeResultImages({ + images: normalizeResultImages(result.images), + skuItemId: intakeSku.id, + renderId, + mode, + }) + : workbenchSku ? await persistTemporaryWorkbenchResultImages({ images: normalizeResultImages(result.images), createdBy, diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.permanent-storage.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.permanent-storage.ts new file mode 100644 index 00000000..881cc125 --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.permanent-storage.ts @@ -0,0 +1,93 @@ +import type { AssistedAssemblyAiRenderImageDTO } from './assisted-assembly-ai-render.records' +import { + cleanWorkbenchResultBackground, + shouldCleanWorkbenchResultBackground, +} from './assisted-assembly-ai-render.background' +import { + fetchImageBuffer, + normalizeWorkbenchResultOutputSize, +} from './assisted-assembly-ai-render.temp-storage' +import { uploadBufferToObjectStorageWithMetadata } from '@/lib/storage' + +const PERMANENT_RENDER_STORAGE_PREFIX = 'product-development/ai-renders' + +function normalizeModeSegment(mode?: string | null) { + if (mode === 'white-background') return 'white-background' + if (mode === 'eight-view') return 'eight-view' + return 'scene' +} + +export function buildPermanentIntakeRenderStorageKey(input: { + skuItemId: string + renderId: string + mode?: string | null + kind: 'references' | 'results' + extension: string + index?: number +}) { + const indexPart = typeof input.index === 'number' ? `-${input.index + 1}` : '' + return `${PERMANENT_RENDER_STORAGE_PREFIX}/${input.skuItemId}/${normalizeModeSegment(input.mode)}/${input.renderId}/${input.kind}/image${indexPart}.${input.extension}` +} + +export async function uploadPermanentIntakeReferenceImage(input: { + buffer: Buffer + contentType: string + extension: string + skuItemId: string + renderId: string + mode?: string | null +}) { + return uploadBufferToObjectStorageWithMetadata( + input.buffer, + buildPermanentIntakeRenderStorageKey({ ...input, kind: 'references' }), + input.contentType, + ) +} + +export async function persistPermanentIntakeResultImages(input: { + images: AssistedAssemblyAiRenderImageDTO[] + skuItemId: string + renderId: string + mode?: string | null +}): Promise { + const storedImages: AssistedAssemblyAiRenderImageDTO[] = [] + + for (let index = 0; index < input.images.length; index += 1) { + const image = input.images[index]! + const fetchedImage = await fetchImageBuffer(image.originalUrl) + const cleanedImage = shouldCleanWorkbenchResultBackground(input.mode) + ? await cleanWorkbenchResultBackground(fetchedImage) + : fetchedImage + const normalizedImage = await normalizeWorkbenchResultOutputSize({ + buffer: cleanedImage.buffer, + extension: cleanedImage.extension, + }) + const upload = await uploadBufferToObjectStorageWithMetadata( + normalizedImage.buffer, + buildPermanentIntakeRenderStorageKey({ + skuItemId: input.skuItemId, + renderId: input.renderId, + mode: input.mode, + kind: 'results', + extension: normalizedImage.extension, + index, + }), + normalizedImage.contentType, + ) + + storedImages.push({ + ...(image.id !== undefined ? { id: image.id } : {}), + originalUrl: upload.url, + thumbnailUrl: upload.url, + imageSize: + normalizedImage.width && normalizedImage.height + ? `${normalizedImage.width}x${normalizedImage.height}` + : image.imageSize ?? null, + storageKey: upload.storageKey, + expiresAt: null, + temporary: false, + }) + } + + return storedImages +} diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts index 753ed448..d3486b33 100644 --- a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts @@ -1,4 +1,8 @@ -import type { Prisma } from '@prisma/client' +import type { + IntakeAiRenderOutputMode, + IntakeAiRenderStorageClass, + Prisma, +} from '@prisma/client' import { AssistedAssemblyAiRenderStatus } from '@prisma/client' import { deleteWorkbenchRenderStorageKey, @@ -13,6 +17,11 @@ export interface AssistedAssemblyAiRenderDTO { id: string workbenchSpuId: string | null workbenchSkuId: string | null + intakeSkuItemId: string | null + outputMode: IntakeAiRenderOutputMode | null + viewName: string | null + viewIndex: number | null + storageClass: IntakeAiRenderStorageClass prompt: string negativePrompt: string | null params: Record @@ -165,6 +174,11 @@ export function toAiRenderDTO( id: string workbenchSpuId: string | null workbenchSkuId: string | null + intakeSkuItemId: string | null + outputMode: IntakeAiRenderOutputMode | null + viewName?: string | null + viewIndex?: number | null + storageClass: IntakeAiRenderStorageClass prompt: string negativePrompt: string | null params: Prisma.JsonValue @@ -187,6 +201,11 @@ export function toAiRenderDTO( id: record.id, workbenchSpuId: record.workbenchSpuId, workbenchSkuId: record.workbenchSkuId, + intakeSkuItemId: record.intakeSkuItemId, + outputMode: record.outputMode, + viewName: record.viewName ?? null, + viewIndex: record.viewIndex ?? null, + storageClass: record.storageClass, prompt: record.prompt, negativePrompt: record.negativePrompt, params: toRecord(record.params), diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts index 59f4ff1e..e96164cc 100644 --- a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts @@ -6,6 +6,7 @@ import { aiRenderWorkbenchService, assertCanDeleteAiRenderWorkbenchImage, } from './ai-render-workbench.service' +import { intakeAiAssetService } from './intake-ai-asset.service' import { decodeReferenceImageDataUrl, normalizeRequiredText, trimToNull } from './assisted-assembly-ai-render.validation' import { createAssistedAssemblyAiRender } from './assisted-assembly-ai-render.create' import type { CreateAssistedAssemblyAiRenderInput } from './assisted-assembly-ai-render.create' @@ -140,6 +141,24 @@ export class AssistedAssemblyAiRenderService { return records.map((record) => toAiRenderDTO(record)) } + async listForIntakeSku( + skuId: string, + user: AuthUser, + options?: { limit?: number }, + ): Promise { + await cleanupExpiredWorkbenchRenderFilesSafely() + const sku = await intakeAiAssetService.getSku(skuId, user) + const limit = Math.min(Math.max(options?.limit ?? 24, 1), 60) + const records = await prisma.assistedAssemblyAiRender.findMany({ + where: { intakeSkuItemId: sku.id }, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { creator: { select: { name: true } } }, + }) + + return records.map((record) => toAiRenderDTO(record)) + } + async getForUser(id: string, user: AuthUser): Promise { const normalizedId = normalizeRequiredText(id, 'AI 闁汇垻鍠庡ù妯兼媼閺夎法绉?ID') const createdBy = normalizeRequiredText(user.id, 'User ID') @@ -148,13 +167,16 @@ export class AssistedAssemblyAiRenderService { include: { creator: { select: { name: true } } }, }) - if (!record || (!record.workbenchSkuId && record.createdBy !== createdBy)) { + if (!record || (!record.workbenchSkuId && !record.intakeSkuItemId && record.createdBy !== createdBy)) { throw new AppError(ErrorCode.FILE_NOT_FOUND, 'AI render record not found') } if (record.workbenchSkuId) { await aiRenderWorkbenchService.getSkuForRender(record.workbenchSkuId, user) } + if (record.intakeSkuItemId) { + await intakeAiAssetService.getSku(record.intakeSkuItemId, user) + } return toAiRenderDTO(record, user.name) } @@ -168,12 +190,13 @@ export class AssistedAssemblyAiRenderService { id: true, createdBy: true, workbenchSkuId: true, + intakeSkuItemId: true, referenceImageUrl: true, resultImages: true, params: true, }, }) - if (!record || (!record.workbenchSkuId && record.createdBy !== createdBy)) { + if (!record || (!record.workbenchSkuId && !record.intakeSkuItemId && record.createdBy !== createdBy)) { throw new AppError(ErrorCode.FILE_NOT_FOUND, 'AI render record not found') } @@ -200,6 +223,29 @@ export class AssistedAssemblyAiRenderService { } } } + if (record.intakeSkuItemId) { + assertCanDeleteAiRenderWorkbenchImage(user) + await intakeAiAssetService.getSku(record.intakeSkuItemId, user) + const imageIndex = options?.imageIndex + const images = normalizeResultImages(record.resultImages) + if (typeof imageIndex === 'number' && imageIndex >= 0 && images.length > 1) { + const nextImages = images.filter((_, index) => index !== imageIndex) + if (nextImages.length > 0 && nextImages.length !== images.length) { + const updateResult = await prisma.assistedAssemblyAiRender.updateMany({ + where: { id: record.id }, + data: { resultImages: nextImages as unknown as Prisma.InputJsonValue }, + }) + if (updateResult.count > 0) { + const deleteResults = await deleteRenderImageStorageFile(images[imageIndex]!) + logWorkbenchRenderStorageDeleteFailures('delete-intake-single-image', deleteResults, { + recordId: record.id, + imageIndex, + }) + } + return + } + } + } const deleteResults = await deleteRenderStorageFiles(record) logWorkbenchRenderStorageDeleteFailures('delete-record', deleteResults, { @@ -223,7 +269,27 @@ export class AssistedAssemblyAiRenderService { const createdBy = normalizeRequiredText(user.id, 'User ID') await cleanupExpiredWorkbenchRenderFilesSafely() await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely() - await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId, user) + await intakeAiAssetService.getSkuForRender(input.workbenchSkuId, user) + const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl) + + return persistTemporaryWorkbenchDetailReferenceImage({ + buffer: referenceImage.buffer, + contentType: referenceImage.contentType, + extension: referenceImage.extension, + createdBy, + }) + } + + async createIntakeDetailReference( + input: { + intakeSkuItemId: string + referenceImageDataUrl: string + }, + user: AuthUser, + ) { + const createdBy = normalizeRequiredText(user.id, 'User ID') + await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely() + await intakeAiAssetService.getSkuForRender(input.intakeSkuItemId, user) const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl) return persistTemporaryWorkbenchDetailReferenceImage({ @@ -238,6 +304,7 @@ export class AssistedAssemblyAiRenderService { input: { storageKey: string workbenchSkuId?: string | null + intakeSkuItemId?: string | null }, user: AuthUser, ): Promise { @@ -252,6 +319,9 @@ export class AssistedAssemblyAiRenderService { if (trimToNull(input.workbenchSkuId)) { await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId!, user) } + if (trimToNull(input.intakeSkuItemId)) { + await intakeAiAssetService.getSkuForRender(input.intakeSkuItemId!, user) + } await deleteWorkbenchRenderStorageKey(storageKey) } diff --git a/furniture-3d-system/src/server/services/barcode-print.service.ts b/furniture-3d-system/src/server/services/barcode-print.service.ts index e385d3bf..3e952914 100644 --- a/furniture-3d-system/src/server/services/barcode-print.service.ts +++ b/furniture-3d-system/src/server/services/barcode-print.service.ts @@ -8,6 +8,7 @@ import { mergeBarcodePrintImportRows } from '@/app/intake/barcode-print/barcodeP import { prisma } from '@/lib/prisma' import type { AuthUser } from '@/server/core/types' import { AppError, ErrorCode } from '@/server/core/errors' +import { canManageBarcodePrintImports } from '@/lib/role-scope' import { intakeNonIntegratedProductService } from './intake-non-integrated-product.service' import { intakeNonIntegratedScopeService } from './intake-non-integrated-scope.service' @@ -196,6 +197,13 @@ class BarcodePrintService { } async saveImportRecords(user: AuthUser, records: SaveBarcodePrintImportRecordInput[]) { + if (!canManageBarcodePrintImports(user)) { + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + '只有设计师和管理员可以导入条码与品牌货号。', + ) + } + if (!Array.isArray(records) || records.length === 0) { throw new AppError( ErrorCode.VALIDATION_REQUIRED_FIELD, @@ -260,6 +268,13 @@ class BarcodePrintService { } async deleteImportRecords(user: AuthUser, ids: unknown[]) { + if (!canManageBarcodePrintImports(user)) { + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + '只有设计师和管理员可以删除导入数据。', + ) + } + const normalizedIds = Array.isArray(ids) ? Array.from( new Set( diff --git a/furniture-3d-system/src/server/services/dingtalk-todo.service.ts b/furniture-3d-system/src/server/services/dingtalk-todo.service.ts index cf80d0c0..f9aa518a 100644 --- a/furniture-3d-system/src/server/services/dingtalk-todo.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-todo.service.ts @@ -36,9 +36,9 @@ export type DingtalkTodoPriority = 10 | 20 | 30 | 40 export interface DingtalkTodoCreateInput { operatorUnionId: string - creatorUserId: string - executorUserIds: string[] - participantUserIds?: string[] + creatorUnionId: string + executorUnionIds: string[] + participantUnionIds?: string[] title: string description: string detailUrl: string @@ -56,7 +56,7 @@ export interface DingtalkTodoUpdateInput extends DingtalkTodoCreateInput { export interface DingtalkTodoExecutorStatusInput { operatorUnionId: string taskId: string - executorUserId: string + executorUnionId: string completed: boolean } @@ -164,17 +164,17 @@ class DingtalkTodoService { } async createTask(input: DingtalkTodoCreateInput): Promise { - const participantUserIds = Array.from( - new Set([input.creatorUserId, ...(input.participantUserIds ?? []), ...input.executorUserIds].filter(Boolean)), + const participantUnionIds = Array.from( + new Set([input.creatorUnionId, ...(input.participantUnionIds ?? []), ...input.executorUnionIds].filter(Boolean)), ) const payload = await this.request(buildTodoTaskUrl(input.operatorUnionId), { method: 'POST', body: JSON.stringify({ sourceId: input.sourceId, - creatorId: input.creatorUserId, - executorIds: input.executorUserIds, - participantIds: participantUserIds, + creatorId: input.creatorUnionId, + executorIds: input.executorUnionIds, + participantIds: participantUnionIds, title: input.title, description: input.description, detailUrl: input.detailUrl, @@ -189,8 +189,8 @@ class DingtalkTodoService { } async updateTask(input: DingtalkTodoUpdateInput): Promise { - const participantUserIds = Array.from( - new Set([input.creatorUserId, ...(input.participantUserIds ?? []), ...input.executorUserIds].filter(Boolean)), + const participantUnionIds = Array.from( + new Set([input.creatorUnionId, ...(input.participantUnionIds ?? []), ...input.executorUnionIds].filter(Boolean)), ) const payload = await this.request( @@ -199,9 +199,9 @@ class DingtalkTodoService { method: 'PUT', body: JSON.stringify({ sourceId: input.sourceId, - creatorId: input.creatorUserId, - executorIds: input.executorUserIds, - participantIds: participantUserIds, + creatorId: input.creatorUnionId, + executorIds: input.executorUnionIds, + participantIds: participantUnionIds, title: input.title, description: input.description, detailUrl: input.detailUrl, @@ -226,7 +226,7 @@ class DingtalkTodoService { await this.request>(buildExecutorStatusUrl(input.operatorUnionId, input.taskId), { method: 'PUT', body: JSON.stringify({ - executorId: input.executorUserId, + executorId: input.executorUnionId, status: input.completed ? 'COMPLETED' : 'TODO', }), }) diff --git a/furniture-3d-system/src/server/services/intake-ai-asset.mapper.ts b/furniture-3d-system/src/server/services/intake-ai-asset.mapper.ts new file mode 100644 index 00000000..b6447508 --- /dev/null +++ b/furniture-3d-system/src/server/services/intake-ai-asset.mapper.ts @@ -0,0 +1,95 @@ +import type { Prisma } from '@prisma/client' +import type { + IntakeAiCatalogProductRecord, + IntakeAiSkuRecord, +} from '@/server/repositories/intake-ai-asset.repository' + +export const DEFAULT_INTAKE_AI_MATERIAL_COLOR = '#d8c6ad' + +export interface IntakeAiSkuDTO { + id: string + productId: string + skuItemId: string + barcode: string + name: string + modelName: string + color: string + material: string + modelUrl: string + modelVersionId: string | null + modelFileName: string | null + materialVersionId: string | null + materialColor: string + materialTextureUrl?: string + nodeMaterials?: Record + uvConfig?: Record +} + +export interface IntakeAiSpuDTO { + id: string + name: string + description: string + skus: IntakeAiSkuDTO[] +} + +export function trimIntakeAiText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function toObject(value: Prisma.JsonValue | null | undefined) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +export function toIntakeAiSkuDTO(sku: IntakeAiSkuRecord): IntakeAiSkuDTO { + const modelVersion = sku.aiProfile?.currentModelVersion ?? null + const materialVersion = sku.aiProfile?.currentMaterialVersion ?? null + const barcode = trimIntakeAiText(sku.barcode) + const canonicalModelUrl = trimIntakeAiText(sku.model3dDataUrl) + const versionModelUrl = + trimIntakeAiText(modelVersion?.previewUrl) || trimIntakeAiText(modelVersion?.sourceUrl) + const modelVersionMatchesCanonical = + !canonicalModelUrl || canonicalModelUrl === versionModelUrl + + return { + id: sku.id, + productId: sku.productId, + skuItemId: sku.skuItemId, + barcode, + name: + barcode || + trimIntakeAiText(sku.skuCode) || + trimIntakeAiText(sku.specName) || + '未命名条码', + modelName: trimIntakeAiText(sku.model), + color: trimIntakeAiText(sku.color), + material: trimIntakeAiText(sku.material), + modelUrl: canonicalModelUrl || versionModelUrl, + modelVersionId: modelVersionMatchesCanonical ? modelVersion?.id ?? null : null, + modelFileName: modelVersionMatchesCanonical ? modelVersion?.originalFileName ?? null : null, + materialVersionId: materialVersion?.id ?? null, + materialColor: materialVersion?.materialColor || DEFAULT_INTAKE_AI_MATERIAL_COLOR, + ...(materialVersion?.textureUrl ? { materialTextureUrl: materialVersion.textureUrl } : {}), + ...(toObject(materialVersion?.nodeMaterials) ? { + nodeMaterials: toObject(materialVersion?.nodeMaterials), + } : {}), + ...(toObject(materialVersion?.uvConfig) ? { + uvConfig: toObject(materialVersion?.uvConfig), + } : {}), + } +} + +export function toIntakeAiSpuDTO(product: IntakeAiCatalogProductRecord): IntakeAiSpuDTO { + return { + id: product.id, + name: + trimIntakeAiText(product.spu) || + trimIntakeAiText(product.spuBase) || + '未命名 SPU', + description: trimIntakeAiText(product.profile?.productName), + skus: product.skuItems + .filter((sku) => trimIntakeAiText(sku.barcode)) + .map(toIntakeAiSkuDTO), + } +} diff --git a/furniture-3d-system/src/server/services/intake-ai-asset.service.ts b/furniture-3d-system/src/server/services/intake-ai-asset.service.ts new file mode 100644 index 00000000..224948cc --- /dev/null +++ b/furniture-3d-system/src/server/services/intake-ai-asset.service.ts @@ -0,0 +1,252 @@ +import { createHash, randomUUID } from 'node:crypto' +import path from 'node:path' +import { Prisma } from '@prisma/client' +import { extractPreviewModelFromZip } from '@/lib/model-preview-zip' +import { getAiRenderWorkbenchAccess } from '@/lib/ai-render-workbench-permissions' +import { + deleteObjectWithResult, + readObjectStorageKeyFromUrl, + uploadBufferToObjectStorageWithMetadata, +} from '@/lib/storage' +import { UPLOAD } from '@/server/core/constants' +import { AppError, ErrorCode } from '@/server/core/errors' +import type { AuthUser } from '@/server/core/types' +import { + intakeAiAssetRepository, + type IntakeAiSkuRecord, +} from '@/server/repositories/intake-ai-asset.repository' +import { intakeNonIntegratedProductService } from './intake-non-integrated-product.service' +import { intakeNonIntegratedScopeService } from './intake-non-integrated-scope.service' +import { + DEFAULT_INTAKE_AI_MATERIAL_COLOR, + toIntakeAiSkuDTO, + toIntakeAiSpuDTO, + trimIntakeAiText, + type IntakeAiSkuDTO, + type IntakeAiSpuDTO, +} from './intake-ai-asset.mapper' + +const PRODUCT_PHOTO_WORKSPACE_CATEGORY = '产品实拍' +const MODEL_STORAGE_PREFIX = 'product-development/models' + +export type { IntakeAiSkuDTO, IntakeAiSpuDTO } from './intake-ai-asset.mapper' + +function assertCanGenerate(user: AuthUser) { + if (!getAiRenderWorkbenchAccess(user).canGenerate) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能在 AI 出图工作台生成图片') + } +} + +function assertCanView(user: AuthUser) { + if (!getAiRenderWorkbenchAccess(user).canView) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能访问 AI 出图资料') + } +} + +function normalizeModelKey(sku: Pick) { + const source = + trimIntakeAiText(sku.model) || + trimIntakeAiText(sku.skuCode) || + trimIntakeAiText(sku.barcode) || + sku.id + return source.toUpperCase().replace(/\s+/g, ' ').slice(0, 160) +} + +function sanitizeExtension(fileName: string) { + return path.extname(fileName).toLowerCase().replace(/[^a-z0-9.]/g, '') +} + +function assertSupportedModelFile(file: File) { + const extension = sanitizeExtension(file.name) + if (!['.zip', '.glb', '.gltf', '.obj'].includes(extension)) { + throw new AppError( + ErrorCode.FILE_TYPE_NOT_ALLOWED, + '仅支持 ZIP、GLB、GLTF 或 OBJ 模型文件', + ) + } + if (file.size <= 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '模型文件为空') + } + if (file.size > UPLOAD.MAX_FILE_SIZE) { + throw new AppError(ErrorCode.FILE_TOO_LARGE, '模型文件超过上传大小限制') + } +} + +function jsonObjectOrNull(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return Prisma.JsonNull + } + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue +} + +export class IntakeAiAssetService { + async listCatalog(query: string, user: AuthUser): Promise { + assertCanView(user) + const products = await intakeAiAssetRepository.findCatalogProducts(query) + return products + .filter((product) => intakeNonIntegratedScopeService.canReadRecord(product, user)) + .map(toIntakeAiSpuDTO) + .filter((product) => product.skus.length > 0) + } + + async getProductWorkspace(productId: string, user: AuthUser): Promise { + assertCanView(user) + const product = await intakeAiAssetRepository.findProductById(productId) + if (!product || !intakeNonIntegratedScopeService.canReadRecord(product, user)) { + throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这条产品资料') + } + return toIntakeAiSpuDTO(product) + } + + async getSkuForRender(skuDatabaseId: string, user: AuthUser): Promise { + assertCanGenerate(user) + const dto = await this.getSku(skuDatabaseId, user) + if (!dto.modelUrl) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '当前条码还没有模型') + } + return dto + } + + async getSku(skuDatabaseId: string, user: AuthUser): Promise { + assertCanView(user) + const sku = await intakeAiAssetRepository.findSkuByDatabaseId(skuDatabaseId) + if (!sku || !intakeNonIntegratedScopeService.canReadRecord(sku.product, user)) { + throw new AppError(ErrorCode.SKU_NOT_FOUND, '没有找到这个条码') + } + return toIntakeAiSkuDTO(sku as unknown as IntakeAiSkuRecord) + } + + async uploadModel(input: { + productId: string + skuItemId: string + file: File + user: AuthUser + }): Promise { + assertSupportedModelFile(input.file) + await intakeNonIntegratedProductService.assertCanWrite( + input.productId, + input.user, + PRODUCT_PHOTO_WORKSPACE_CATEGORY, + ) + const sku = await intakeAiAssetRepository.findSkuByProductKey( + input.productId, + input.skuItemId, + ) + if (!sku) { + throw new AppError(ErrorCode.SKU_NOT_FOUND, '没有找到这个条码') + } + + const sourceBuffer = Buffer.from(await input.file.arrayBuffer()) + const checksumSha256 = createHash('sha256').update(sourceBuffer).digest('hex') + const modelKey = normalizeModelKey(sku as unknown as IntakeAiSkuRecord) + const asset = await intakeAiAssetRepository.getOrCreateModelAsset({ + productId: input.productId, + modelKey, + displayName: trimIntakeAiText(sku.model) || trimIntakeAiText(sku.barcode), + createdBy: input.user.id, + }) + const versionNo = await intakeAiAssetRepository.getNextModelVersionNo(asset.id) + const versionId = randomUUID() + const sourceExtension = sanitizeExtension(input.file.name) || '.bin' + const sourceUpload = await uploadBufferToObjectStorageWithMetadata( + sourceBuffer, + `${MODEL_STORAGE_PREFIX}/${asset.id}/versions/${versionId}/source/original${sourceExtension}`, + input.file.type || 'application/octet-stream', + ) + const uploadedKeys = new Set([sourceUpload.storageKey]) + + try { + let previewUpload = sourceUpload + let previewFile = input.file + if (sourceExtension === '.zip') { + const extracted = await extractPreviewModelFromZip(input.file) + if (!extracted.success) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, extracted.error) + } + previewFile = extracted.modelFile + const previewExtension = sanitizeExtension(previewFile.name) || '.glb' + previewUpload = await uploadBufferToObjectStorageWithMetadata( + Buffer.from(await previewFile.arrayBuffer()), + `${MODEL_STORAGE_PREFIX}/${asset.id}/versions/${versionId}/preview/model${previewExtension}`, + previewFile.type || 'application/octet-stream', + ) + uploadedKeys.add(previewUpload.storageKey) + } + + const initializeModelGroup = !sku.aiProfile?.currentModelVersionId + await intakeAiAssetRepository.createModelVersionAndBind({ + version: { + id: versionId, + assetId: asset.id, + versionNo, + originalFileName: input.file.name, + mimeType: input.file.type || 'application/octet-stream', + fileSize: input.file.size, + checksumSha256, + sourceStorageKey: sourceUpload.storageKey, + sourceUrl: sourceUpload.url, + previewStorageKey: previewUpload.storageKey, + previewUrl: previewUpload.url, + createdBy: input.user.id, + }, + selectedSkuDatabaseId: sku.id, + productId: input.productId, + modelValue: sku.model, + previewUrl: previewUpload.url, + createdBy: input.user.id, + initializeModelGroup: + initializeModelGroup && Boolean(trimIntakeAiText(sku.model)), + }) + } catch (error) { + await Promise.all(Array.from(uploadedKeys).map((key) => deleteObjectWithResult(key))) + throw error + } + + const updatedSku = await intakeAiAssetRepository.findSkuByDatabaseId(sku.id) + if (!updatedSku) { + throw new AppError(ErrorCode.SKU_NOT_FOUND, '模型已上传,但条码刷新失败') + } + return toIntakeAiSkuDTO(updatedSku as unknown as IntakeAiSkuRecord) + } + + async saveMaterial(input: { + productId: string + skuDatabaseId: string + materialColor?: string | null + materialTextureUrl?: string | null + nodeMaterials?: unknown + uvConfig?: unknown + user: AuthUser + }): Promise { + const sku = await intakeAiAssetRepository.findSkuByDatabaseId(input.skuDatabaseId) + if (!sku) { + throw new AppError(ErrorCode.SKU_NOT_FOUND, '没有找到这个条码') + } + if (sku.productId !== input.productId) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '条码不属于当前产品') + } + await intakeNonIntegratedProductService.assertCanWrite( + input.productId, + input.user, + PRODUCT_PHOTO_WORKSPACE_CATEGORY, + ) + const textureUrl = trimIntakeAiText(input.materialTextureUrl) || null + await intakeAiAssetRepository.createMaterialVersion({ + skuDatabaseId: sku.id, + createdBy: input.user.id, + materialColor: + trimIntakeAiText(input.materialColor) || DEFAULT_INTAKE_AI_MATERIAL_COLOR, + textureStorageKey: textureUrl ? readObjectStorageKeyFromUrl(textureUrl) : null, + textureUrl, + nodeMaterials: jsonObjectOrNull(input.nodeMaterials), + uvConfig: jsonObjectOrNull(input.uvConfig), + }) + const updatedSku = await intakeAiAssetRepository.findSkuByDatabaseId(sku.id) + if (!updatedSku) { + throw new AppError(ErrorCode.SKU_NOT_FOUND, '材质已保存,但条码刷新失败') + } + return toIntakeAiSkuDTO(updatedSku as unknown as IntakeAiSkuRecord) + } +} + +export const intakeAiAssetService = new IntakeAiAssetService() diff --git a/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts b/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts index a9ca6afb..d0d31d83 100644 --- a/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts @@ -283,9 +283,9 @@ function buildPayload( return { operatorUnionId: owner.dingtalkUnionId, - creatorUserId: owner.dingtalkUserId, - executorUserIds: [owner.dingtalkUserId], - participantUserIds: [owner.dingtalkUserId], + creatorUnionId: owner.dingtalkUnionId, + executorUnionIds: [owner.dingtalkUnionId], + participantUnionIds: [owner.dingtalkUnionId], title: buildTodoTitle(item, stageName), description: buildTodoDescription(item, stageName), detailUrl, diff --git a/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts b/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts index 8b76dbfc..d36fab65 100644 --- a/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts @@ -86,7 +86,7 @@ type CandidateIdentity = { } function normalizeIdentity(value: CandidateIdentity) { - return `${value.source}:${value.dingtalkUserId ?? ''}:${value.dingtalkUnionId ?? ''}` + return `${value.dingtalkUserId ?? ''}:${value.dingtalkUnionId ?? ''}:${value.name}` } export async function resolveOaBoardTodoOwners(