feat(asset-center): 重构基材规格与CAD规格款口径

This commit is contained in:
WuYongJie 2026-04-08 18:09:09 +08:00
parent f0bf6c09a2
commit 6723c8f5f7
37 changed files with 2067 additions and 822 deletions

View File

@ -13,20 +13,8 @@
## 当前未完成项
### 待联调确认 (全栈 / 后端 Agent 负责)
- [ ] **资产中心资产族新流程人工验收**
归属:`联调确认`
说明:确认资产中心现在已改成"先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录"的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
- [ ] **资产族品类系列 + 中文连接角色维护人工验收**
归属:`联调确认`
说明:已改成在资产族弹窗内选择品类系列,并维护中文连接角色(支持新增 / 编辑 / 删除)。需重点确认:系列切换后推荐角色是否合理、旧数据回显是否稳定、保存后列表和 CAD 导入提示是否一致。
### 待前端修正 (前端 UI Agent 负责)
- [ ] **包材资料库页面视觉联调**
归属:`联调确认`
归属:`前端负责`
说明:确认该页面视觉是否已统一到主线风格。
- [ ] **基础资料库 CRUD 联调**
归属:`联调确认`
说明:确认 `基础资料库` 页的基材新增/编辑/删除、材质分类新增/编辑/删除、饰面新增/编辑/删除全部可用;删除后回到资产中心,CAD 导入弹窗、规格款弹窗和 3D 右键快捷菜单都能读到最新数据。

View File

@ -42,6 +42,21 @@
## 3. 默认操作步骤
先加一个固定前置动作:
```bash
git status --short
```
如果这里不是空的,先把当前改动处理好再同步主线:
- 要保留并提交:先提交到你当前分支
- 只是临时改动:先暂存(stash)再继续
不要在“有一堆未提交改动”时直接 `merge main` 或 `rebase main`。
---
如果你现在在长期共享分支 `codex/xuxiake-packaging`,就把下面这 5 行命令原样交给 Codex 执行:
```bash

View File

@ -33,7 +33,6 @@
- 自动组合
- 草稿组
- 正式入库
- 轻仓库
- 新平台公共导航与页面骨架
默认涉及目录:
@ -42,12 +41,10 @@
- `src/app/auto-combinations`
- `src/app/draft-groups`
- `src/app/intake`
- `src/app/warehouse`
- `src/app/api/asset-center`
- `src/app/api/auto-combinations`
- `src/app/api/draft-groups`
- `src/app/api/intake`
- `src/app/api/warehouse`
- `src/components/platform`
- `src/lib/open-product`
- `src/lib/planning`
@ -56,16 +53,19 @@
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/services/planning.service.ts`
- `src/server/services/warehouse.service.ts`
### 包装负责人
### 包装与仓库负责人
负责包装方案增强,以及未来对接新平台的包装结果。
负责包装方案增强、轻仓库工作区,以及未来对接新平台的包装结果与仓库承接。
默认涉及目录:
- `src/app/warehouse`
- `src/app/api/warehouse`
- `src/app/api/pre-skus/[id]/packaging`
- `src/app/api/products/[id]/packaging`
- `src/server/services/warehouse.service.ts`
- `src/server/repositories/warehouse.repository.ts`
- `src/server/services/packaging.service.ts`
- `src/server/repositories/packaging.repository.ts`
- `docs/开发指南/包装方案增强`
@ -73,9 +73,11 @@
默认边界:
- 可以增强包装对象、包装结果、包装展示。
- 可以增强轻仓库里的包装成品、库存展示、库位展示和仓库工作区交互。
- 不直接改自动组合规则。
- 不直接改正式入库规则。
- 不把包装流程做成独立顶层系统入口。
- 不把轻仓库重新拉回成独立主数据源。
- 草稿组详情页当前出现的包装区,只是主线最小联通版占位入口,不是最终包装工作区。
需要先和主线负责人对齐后再改的共享接缝:
@ -83,6 +85,7 @@
- `src/server/services/draft-group.service.ts`
- `src/server/repositories/draft-group.repository.ts`
- `src/server/services/intake.service.ts`
- `src/app/intake`
- `prisma/schema.prisma`
- `src/app/draft-groups/[id]`
@ -138,8 +141,8 @@
- 负责人必须进分支名,不能只用 `feature/...`、`packaging/...`、`dev` 这类泛名字
- `WuYongJie` 主线相关分支统一放 `codex/wuyongjie-mainline...`
- `xuxiake` 包装长期分支固定为 `codex/xuxiake-packaging`
- `xuxiake` 的包装临时任务分支统一放 `codex/xuxiake-packaging-<任务简称>`
- `xuxiake` 包装/仓库长期分支固定为 `codex/xuxiake-packaging`
- `xuxiake` 的包装/仓库临时任务分支统一放 `codex/xuxiake-packaging-<任务简称>`
- 其他负责人也按同一模式命名,不再额外发明新前缀
- 历史分支名不强制重命名,但从现在开始新分支全部按这套规则建
@ -205,7 +208,7 @@
当前项目默认采用这个口径:
- `WuYongJie` 负责持续更新和收口 `main`
- `xuxiake` 长期在 `codex/xuxiake-packaging` 开发包装线
- `xuxiake` 长期在 `codex/xuxiake-packaging` 开发包装与仓库线
- 同事不能等做完了再一次性同步,而是要高频同步 `main`
默认同步时机:
@ -248,7 +251,7 @@ git push --force-with-lease origin codex/xuxiake-packaging-costing
怎么选:
- 长期包装分支,用 `merge`
- 长期包装/仓库分支,用 `merge`
- 临时个人任务分支,用 `rebase`
详细操作说明见:`docs/workflow/主线同步操作手册.md`
@ -269,10 +272,10 @@ git push --force-with-lease origin codex/xuxiake-packaging-costing
额外固定动作:
- 包装线涉及共享接缝时,先在任务单里写清“为什么必须碰共享层”。
- `xuxiake` 推包装分支后,要附一段交接说明,明确本次只改了什么。
- `WuYongJie` 收口包装改动时,优先按小任务合并,不一次性吞并长期积压改动。
- 如果任务只是要把包装工作区做完整,默认不要直接在 `src/app/draft-groups/[id]` 里堆实现;先确认是否该独立在包装域内收口。
- 如果这次改动会影响 `intake / warehouse / Product / SKU` 承接链,收口时额外确认:有没有把包装逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。
- `xuxiake` 推包装/仓库分支后,要附一段交接说明,明确本次只改了什么。
- `WuYongJie` 收口包装/仓库改动时,优先按小任务合并,不一次性吞并长期积压改动。
- 如果任务只是要把包装或轻仓库工作区做完整,默认不要直接在 `src/app/draft-groups/[id]` 或 `src/app/intake` 里堆实现;先确认是否该独立在包装/仓库域内收口。
- 如果这次改动会影响 `intake / warehouse / Product / SKU` 承接链,收口时额外确认:有没有把包装或仓库逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。
---

View File

@ -97,15 +97,19 @@
---
## 3. 包装协作边界
## 3. 包装与仓库协作边界
### 当前包装入口分两条
### 当前同事协作入口分四条
- 轻仓库页面:`src/app/warehouse`
- 轻仓库接口:`src/app/api/warehouse`
- 新平台承接口:`src/app/api/pre-skus/[id]/packaging/route.ts`
- 旧正式产品口:`src/app/api/products/[id]/packaging`
### 主要服务
### 主要服务与仓库
- `src/server/services/warehouse.service.ts`
- `src/server/repositories/warehouse.repository.ts`
- `src/server/services/packaging.service.ts`
- `src/server/repositories/packaging.repository.ts`
@ -113,18 +117,21 @@
- 包装结果最终按单个 `PreSku` 保存。
- 正式产品包装链保留,作为兼容与发布后使用。
- 轻仓库工作区由同事负责,但轻仓库承接的正式 `Product / SKU` 仍然属于主线真相,不在仓库侧重复造数。
- 工艺图组合本期只预留接口,不在主线里单独展开。
- 草稿组详情页里的包装区只负责主线最小联通版占位,不是最终包装工作区。
- 轻仓库可以增强包装成品、库存、库位和展示交互,但不能绕过正式入库单独制造主链结果。
### 包装线可读但不默认直改的共享接缝
### 包装/仓库线可读但不默认直改的共享接缝
- `src/server/services/draft-group.service.ts`
- `src/server/repositories/draft-group.repository.ts`
- `src/server/services/intake.service.ts`
- `src/app/intake`
- `prisma/schema.prisma`
- `src/app/draft-groups/[id]`
这些文件不是包装线的默认自由修改区。
这些文件不是包装/仓库线的默认自由修改区。
如果任务必须改这里,先在任务单里写清原因、影响范围,再由主线负责人确认后再动。
白话理解:
@ -132,13 +139,14 @@
- 可以看,可以评估,可以提出接缝需求
- 不能在没有对齐的情况下直接开改
- 特别是 `draft-group`、`intake`、`schema.prisma` 这几处,本阶段只允许做边界说明,不允许顺手把实现也改掉
- 如果只是为了补包装 UI、材料选择器、最低成本方案,不应默认落在这些共享接缝里完成
- 如果只是为了补包装 UI、材料选择器、最低成本方案,或补轻仓库展示与交互,不应默认落在这些共享接缝里完成
- 主线收口人检查同事分支时,如果这些共享接缝被改了,默认不要直接整支 `merge`;优先确认是否该 `cherry-pick` 或手工整合
### 包装负责人不要乱碰的地方
### 包装与仓库负责人不要乱碰的地方
- 自动组合生成规则
- 草稿组转正式 SKU 的规则
- 正式入库的核心落库规则
- 旧 CAD 的板件/孔位/五金推导链
---
@ -239,12 +247,14 @@
- `src/server/services/planning.service.ts`
- `src/server/services/draft-group.service.ts`
### 做包装
### 做包装或仓库协作
先看:
- `docs/workflow/团队协作模式.md`
- `docs/开发指南/包装方案增强/包装方案增强-同事交接说明.md`
- `src/app/warehouse`
- `src/app/api/warehouse`
- `src/app/api/pre-skus/[id]/packaging/route.ts`
- `src/server/services/packaging.service.ts`
- `src/server/services/draft-group.service.ts`

View File

@ -54,9 +54,9 @@
### 包装协作
- 当前包装由同事负责
- 主线只保证包装在草稿组和 `PreSku` 上有稳定承接口
- 不把包装重新做成独立顶层主入口
- 当前包装与轻仓库由同事负责
- 主线只保证包装在草稿组和 `PreSku` 上有稳定承接口,并保证轻仓库承接正式入库结果
- 不把包装或轻仓库重新做成独立顶层主入口
## 已落地的基础能力

View File

@ -0,0 +1,158 @@
-- 资产中心基材规格化 + 规格款损耗/预估成本
-- 1. 新字典表
CREATE TABLE "SubstrateCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubstrateCategory_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SubstrateEnvironmentGrade" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubstrateEnvironmentGrade_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SubstrateThicknessSpec" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"thicknessMm" DOUBLE PRECISION NOT NULL,
"sheetLengthMm" DOUBLE PRECISION NOT NULL DEFAULT 2440,
"sheetWidthMm" DOUBLE PRECISION NOT NULL DEFAULT 1220,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubstrateThicknessSpec_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SubstrateSurfaceOption" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubstrateSurfaceOption_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SubstrateSystemNameOption" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubstrateSystemNameOption_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SubstrateCategory_name_key" ON "SubstrateCategory"("name");
CREATE UNIQUE INDEX "SubstrateEnvironmentGrade_name_key" ON "SubstrateEnvironmentGrade"("name");
CREATE UNIQUE INDEX "SubstrateThicknessSpec_name_key" ON "SubstrateThicknessSpec"("name");
CREATE UNIQUE INDEX "SubstrateSurfaceOption_name_key" ON "SubstrateSurfaceOption"("name");
CREATE UNIQUE INDEX "SubstrateSystemNameOption_name_key" ON "SubstrateSystemNameOption"("name");
CREATE INDEX "SubstrateCategory_sortOrder_idx" ON "SubstrateCategory"("sortOrder");
CREATE INDEX "SubstrateCategory_isActive_idx" ON "SubstrateCategory"("isActive");
CREATE INDEX "SubstrateEnvironmentGrade_sortOrder_idx" ON "SubstrateEnvironmentGrade"("sortOrder");
CREATE INDEX "SubstrateEnvironmentGrade_isActive_idx" ON "SubstrateEnvironmentGrade"("isActive");
CREATE INDEX "SubstrateThicknessSpec_sortOrder_idx" ON "SubstrateThicknessSpec"("sortOrder");
CREATE INDEX "SubstrateThicknessSpec_isActive_idx" ON "SubstrateThicknessSpec"("isActive");
CREATE INDEX "SubstrateSurfaceOption_sortOrder_idx" ON "SubstrateSurfaceOption"("sortOrder");
CREATE INDEX "SubstrateSurfaceOption_isActive_idx" ON "SubstrateSurfaceOption"("isActive");
CREATE INDEX "SubstrateSystemNameOption_sortOrder_idx" ON "SubstrateSystemNameOption"("sortOrder");
CREATE INDEX "SubstrateSystemNameOption_isActive_idx" ON "SubstrateSystemNameOption"("isActive");
-- 2. 默认字典项,用于回填旧数据
INSERT INTO "SubstrateCategory" ("id", "name", "sortOrder", "isActive", "createdAt", "updatedAt")
VALUES ('seed_substrate_category_default', '默认材质', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name") DO NOTHING;
INSERT INTO "SubstrateEnvironmentGrade" ("id", "name", "sortOrder", "isActive", "createdAt", "updatedAt")
VALUES ('seed_substrate_env_default', 'E1', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name") DO NOTHING;
INSERT INTO "SubstrateThicknessSpec" ("id", "name", "thicknessMm", "sheetLengthMm", "sheetWidthMm", "sortOrder", "isActive", "createdAt", "updatedAt")
VALUES ('seed_substrate_thickness_default', '18', 18, 2440, 1220, 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name") DO NOTHING;
INSERT INTO "SubstrateSurfaceOption" ("id", "name", "sortOrder", "isActive", "createdAt", "updatedAt")
VALUES ('seed_substrate_surface_default', '双贴', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name") DO NOTHING;
INSERT INTO "SubstrateSystemNameOption" ("id", "name", "sortOrder", "isActive", "createdAt", "updatedAt")
VALUES ('seed_substrate_system_default', '默认基材', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name") DO NOTHING;
-- 3. 规格款新增字段
ALTER TABLE "AssetVariant"
ADD COLUMN "lossFactor" DOUBLE PRECISION,
ADD COLUMN "estimatedCostPrice" DECIMAL(10,2);
CREATE INDEX "AssetVariant_inheritedFromVariantId_idx" ON "AssetVariant"("inheritedFromVariantId");
-- 4. 基材表扩展字段,先可空回填,再收紧
ALTER TABLE "Substrate"
DROP INDEX IF EXISTS "Substrate_name_key";
ALTER TABLE "Substrate"
ADD COLUMN "categoryId" TEXT,
ADD COLUMN "environmentGradeId" TEXT,
ADD COLUMN "thicknessSpecId" TEXT,
ADD COLUMN "surfaceOptionId" TEXT,
ADD COLUMN "systemNameOptionId" TEXT,
ADD COLUMN "suggestedPrice" DECIMAL(10,2);
UPDATE "Substrate"
SET
"categoryId" = COALESCE("categoryId", 'seed_substrate_category_default'),
"environmentGradeId" = COALESCE("environmentGradeId", 'seed_substrate_env_default'),
"thicknessSpecId" = COALESCE("thicknessSpecId", 'seed_substrate_thickness_default'),
"surfaceOptionId" = COALESCE("surfaceOptionId", 'seed_substrate_surface_default'),
"systemNameOptionId" = COALESCE("systemNameOptionId", 'seed_substrate_system_default');
ALTER TABLE "Substrate"
ALTER COLUMN "categoryId" SET NOT NULL,
ALTER COLUMN "environmentGradeId" SET NOT NULL,
ALTER COLUMN "thicknessSpecId" SET NOT NULL,
ALTER COLUMN "surfaceOptionId" SET NOT NULL,
ALTER COLUMN "systemNameOptionId" SET NOT NULL;
ALTER TABLE "Substrate" DROP COLUMN IF EXISTS "densityKgM3";
CREATE INDEX "Substrate_categoryId_idx" ON "Substrate"("categoryId");
CREATE INDEX "Substrate_environmentGradeId_idx" ON "Substrate"("environmentGradeId");
CREATE INDEX "Substrate_thicknessSpecId_idx" ON "Substrate"("thicknessSpecId");
CREATE INDEX "Substrate_surfaceOptionId_idx" ON "Substrate"("surfaceOptionId");
CREATE INDEX "Substrate_systemNameOptionId_idx" ON "Substrate"("systemNameOptionId");
CREATE UNIQUE INDEX "Substrate_categoryId_environmentGradeId_thicknessSpecId_sur_key"
ON "Substrate"("categoryId", "environmentGradeId", "thicknessSpecId", "surfaceOptionId", "systemNameOptionId");
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "SubstrateCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_environmentGradeId_fkey"
FOREIGN KEY ("environmentGradeId") REFERENCES "SubstrateEnvironmentGrade"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_thicknessSpecId_fkey"
FOREIGN KEY ("thicknessSpecId") REFERENCES "SubstrateThicknessSpec"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_surfaceOptionId_fkey"
FOREIGN KEY ("surfaceOptionId") REFERENCES "SubstrateSurfaceOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_systemNameOptionId_fkey"
FOREIGN KEY ("systemNameOptionId") REFERENCES "SubstrateSystemNameOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -436,9 +436,19 @@ model PlanningDraft {
model Substrate {
id String @id @default(cuid())
code String @unique
name String @unique
name String
description String? @db.Text
densityKgM3 Float?
categoryId String
category SubstrateCategory @relation(fields: [categoryId], references: [id])
environmentGradeId String
environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id])
thicknessSpecId String
thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id])
surfaceOptionId String
surfaceOption SubstrateSurfaceOption @relation(fields: [surfaceOptionId], references: [id])
systemNameOptionId String
systemNameOption SubstrateSystemNameOption @relation(fields: [systemNameOptionId], references: [id])
suggestedPrice Decimal? @db.Decimal(10, 2)
metadata Json?
isActive Boolean @default(true)
@ -452,6 +462,90 @@ model Substrate {
@@index([isActive])
@@index([createdBy])
@@index([categoryId])
@@index([environmentGradeId])
@@index([thicknessSpecId])
@@index([surfaceOptionId])
@@index([systemNameOptionId])
@@unique([categoryId, environmentGradeId, thicknessSpecId, surfaceOptionId, systemNameOptionId])
}
model SubstrateCategory {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
substrates Substrate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sortOrder])
@@index([isActive])
}
model SubstrateEnvironmentGrade {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
substrates Substrate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sortOrder])
@@index([isActive])
}
model SubstrateThicknessSpec {
id String @id @default(cuid())
name String @unique
thicknessMm Float
sheetLengthMm Float @default(2440)
sheetWidthMm Float @default(1220)
sortOrder Int @default(0)
isActive Boolean @default(true)
substrates Substrate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sortOrder])
@@index([isActive])
}
model SubstrateSurfaceOption {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
substrates Substrate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sortOrder])
@@index([isActive])
}
model SubstrateSystemNameOption {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
substrates Substrate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sortOrder])
@@index([isActive])
}
model AssetFamily {
@ -521,6 +615,8 @@ model AssetVariant {
businessData Json?
weightKg Float?
costPrice Decimal? @db.Decimal(10, 2)
lossFactor Float?
estimatedCostPrice Decimal? @db.Decimal(10, 2)
inheritedFromVariantId String?
inheritedFromVariant AssetVariant? @relation("AssetVariantInheritance", fields: [inheritedFromVariantId], references: [id], onDelete: SetNull)
@ -554,6 +650,7 @@ model AssetVariant {
@@index([reviewedById])
@@index([rejectedById])
@@index([lastModifiedById])
@@index([inheritedFromVariantId])
}
model SeriesTemplate {

View File

@ -63,8 +63,7 @@ export async function POST(request: NextRequest) {
sizeLabel?: string | null
substrateId?: string | null
finishId?: string | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null
}
try {
@ -82,8 +81,7 @@ export async function POST(request: NextRequest) {
sizeLabel: formData.get('sizeLabel'),
substrateId: formData.get('substrateId'),
finishId: formData.get('finishId'),
weightKg: parseNullableNumber(formData.get('weightKg')),
costPrice: parseNullableNumber(formData.get('costPrice')),
lossFactor: parseNullableNumber(formData.get('lossFactor')),
})
if (!parsed.success) {

View File

@ -0,0 +1,71 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService } from '@/server/services'
import { substrateDictionaryUpdateSchema } from '../../../platform/schemas'
interface RouteParams {
params: Promise<{ id: string }>
}
export async function PUT(request: NextRequest, context: RouteParams) {
return handleRequest(
request,
async (ctx) => {
const rawBody = await request.json().catch(() => null)
const parsed = substrateDictionaryUpdateSchema.safeParse(rawBody)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
`基材字典参数不正确: ${parsed.error.message}`,
400,
ctx.requestId,
)
}
try {
const { id } = await context.params
const { type, ...input } = parsed.data
const option = await assetCenterService.updateSubstrateDictionary(type, id, input)
return jsonSuccess({ option }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function DELETE(request: NextRequest, context: RouteParams) {
return handleRequest(
request,
async (ctx) => {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
if (
type !== 'CATEGORY' &&
type !== 'ENVIRONMENT_GRADE' &&
type !== 'THICKNESS_SPEC' &&
type !== 'SURFACE_OPTION' &&
type !== 'SYSTEM_NAME_OPTION'
) {
return jsonError(ErrorCode.VALIDATION_FAILED, '缺少有效的字典类型', 400, ctx.requestId)
}
try {
const { id } = await context.params
await assetCenterService.deleteSubstrateDictionary(type, id)
return jsonSuccess({ success: true }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,54 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService } from '@/server/services'
import {
substrateDictionaryCreateSchema,
} from '../../platform/schemas'
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const dictionaries = await assetCenterService.getSubstrateDictionaryBundle()
return jsonSuccess({ dictionaries }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const rawBody = await request.json().catch(() => null)
const parsed = substrateDictionaryCreateSchema.safeParse(rawBody)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
`基材字典参数不正确: ${parsed.error.message}`,
400,
ctx.requestId,
)
}
try {
const option = await assetCenterService.createSubstrateDictionary(parsed.data)
return jsonSuccess({ option }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -57,6 +57,8 @@ export const assetVariantCreateSchema = z.object({
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
weightKg: z.number().optional().nullable(),
costPrice: z.number().optional().nullable(),
lossFactor: z.number().positive().optional().nullable(),
estimatedCostPrice: z.number().optional().nullable(),
inheritedFromVariantId: z.string().trim().min(1).optional().nullable(),
})
@ -86,6 +88,8 @@ export const assetVariantUpdateSchema = z.object({
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
weightKg: z.number().optional().nullable(),
costPrice: z.number().optional().nullable(),
lossFactor: z.number().positive().optional().nullable(),
estimatedCostPrice: z.number().optional().nullable(),
})
export const assetReviewActionSchema = z.object({
@ -95,21 +99,57 @@ export const assetReviewActionSchema = z.object({
export const substrateCreateSchema = z.object({
code: z.string().trim().min(1),
name: z.string().trim().min(1),
description: z.string().optional().nullable(),
densityKgM3: z.number().optional().nullable(),
categoryId: z.string().trim().min(1),
environmentGradeId: z.string().trim().min(1),
thicknessSpecId: z.string().trim().min(1),
surfaceOptionId: z.string().trim().min(1),
systemNameOptionId: z.string().trim().min(1),
suggestedPrice: z.number().nonnegative().optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
})
export const substrateUpdateSchema = z.object({
code: z.string().trim().min(1).optional(),
name: z.string().trim().min(1).optional(),
description: z.string().optional().nullable(),
densityKgM3: z.number().optional().nullable(),
categoryId: z.string().trim().min(1).optional(),
environmentGradeId: z.string().trim().min(1).optional(),
thicknessSpecId: z.string().trim().min(1).optional(),
surfaceOptionId: z.string().trim().min(1).optional(),
systemNameOptionId: z.string().trim().min(1).optional(),
suggestedPrice: z.number().nonnegative().optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
isActive: z.boolean().optional(),
})
export const substrateDictionaryTypeSchema = z.enum([
'CATEGORY',
'ENVIRONMENT_GRADE',
'THICKNESS_SPEC',
'SURFACE_OPTION',
'SYSTEM_NAME_OPTION',
])
export const substrateDictionaryCreateSchema = z.object({
type: substrateDictionaryTypeSchema,
name: z.string().trim().min(1),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
thicknessMm: z.number().positive().optional().nullable(),
sheetLengthMm: z.number().positive().optional().nullable(),
sheetWidthMm: z.number().positive().optional().nullable(),
})
export const substrateDictionaryUpdateSchema = z.object({
type: substrateDictionaryTypeSchema,
name: z.string().trim().min(1).optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
thicknessMm: z.number().positive().optional().nullable(),
sheetLengthMm: z.number().positive().optional().nullable(),
sheetWidthMm: z.number().positive().optional().nullable(),
})
export const assetCadImportPayloadSchema = z.object({
assetType: z.enum(['BOARD', 'COMPONENT', 'HARDWARE']),
familyMode: assetCadImportFamilyModeSchema,
@ -123,8 +163,7 @@ export const assetCadImportPayloadSchema = z.object({
sizeLabel: z.string().trim().optional().nullable(),
substrateId: z.string().trim().min(1).optional().nullable(),
finishId: z.string().trim().min(1).optional().nullable(),
weightKg: z.number().optional().nullable(),
costPrice: z.number().optional().nullable(),
lossFactor: z.number().positive().optional().nullable(),
}).superRefine((value, ctx) => {
if (value.familyMode === 'existing' && !value.familyId) {
ctx.addIssue({

View File

@ -46,6 +46,7 @@ import {
resolveConnectionRoleLabel,
type ConnectionRoleDefinition,
} from '@/lib/open-product/connection-role-catalog'
import { calculateEstimatedCost } from '@/lib/open-product/substrate-rules'
import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog'
import { cn } from '@/lib/utils'
import {
@ -68,6 +69,7 @@ import type {
AssetVariantDTO,
SeriesTemplateDTO,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
} from '@/server/services'
// ---- Props ----
@ -76,6 +78,7 @@ interface AssetCenterClientProps {
initialFamilies: AssetFamilyDTO[]
initialVariants: AssetVariantDTO[]
initialSubstrates: SubstrateDTO[]
initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO
initialSeriesTemplates: SeriesTemplateDTO[]
finishes: MaterialDTO[]
materialCategories: MaterialCategoryDTO[]
@ -233,14 +236,12 @@ function emptyVariantForm(assetFamilyId: string) {
length: '',
width: '',
thickness: '',
height: '',
modelUrl: '',
mountPointName: 'origin',
mountX: '0',
mountY: '0',
mountZ: '0',
weightKg: '',
costPrice: '',
lossFactor: '1',
available: false,
inheritedFromVariantId: '',
includeLegConnectorRole: false,
@ -269,8 +270,7 @@ function emptyCadImportForm(assetType: ImportableAssetType, useExistingFamily: b
sizeLabel: '',
substrateId: '',
finishId: '',
weightKg: '',
costPrice: '',
lossFactor: '1',
file: null as File | null,
}
}
@ -379,6 +379,7 @@ export default function AssetCenterClient({
initialFamilies,
initialVariants,
initialSubstrates,
initialSubstrateDictionaries: _initialSubstrateDictionaries,
initialSeriesTemplates,
finishes: initialFinishes,
materialCategories: initialMaterialCategories,
@ -447,6 +448,7 @@ export default function AssetCenterClient({
const [cadImportSubmitAttempted, setCadImportSubmitAttempted] = useState(false)
const [cadImportResult, setCadImportResult] = useState<AssetCadImportResult | null>(null)
const [cadImportParsing, setCadImportParsing] = useState(false)
const [cadImportDerivedDimensions, setCadImportDerivedDimensions] = useState<Record<string, number>>({})
const [autoInferredFields, setAutoInferredFields] = useState<Set<string>>(new Set())
const [previewNodeNames, setPreviewNodeNames] = useState<string[]>([])
const [selectedPreviewNodeName, setSelectedPreviewNodeName] = useState<string | null>(null)
@ -516,6 +518,50 @@ export default function AssetCenterClient({
[families, cadImportForm.familyId],
)
const selectedVariantSubstrate = useMemo(
() => substrates.find((item) => item.id === variantForm.substrateId) ?? null,
[substrates, variantForm.substrateId],
)
const isDuplicateVariantMode =
variantModalMode === 'create' && Boolean(variantForm.inheritedFromVariantId)
const variantEstimatedCostPrice = useMemo(
() =>
calculateEstimatedCost({
lengthMm: toOptionalNumber(variantForm.length),
widthMm: toOptionalNumber(variantForm.width),
sheetLengthMm: selectedVariantSubstrate?.sheetLengthMm,
sheetWidthMm: selectedVariantSubstrate?.sheetWidthMm,
suggestedPrice: selectedVariantSubstrate?.suggestedPrice,
lossFactor: toOptionalNumber(variantForm.lossFactor),
}),
[selectedVariantSubstrate, variantForm.length, variantForm.width, variantForm.lossFactor],
)
const selectedCadSubstrate = useMemo(
() => substrates.find((item) => item.id === cadImportForm.substrateId) ?? null,
[substrates, cadImportForm.substrateId],
)
const cadImportEstimatedCostPrice = useMemo(
() =>
calculateEstimatedCost({
lengthMm:
typeof cadImportDerivedDimensions.length === 'number'
? cadImportDerivedDimensions.length
: null,
widthMm:
typeof cadImportDerivedDimensions.width === 'number'
? cadImportDerivedDimensions.width
: null,
sheetLengthMm: selectedCadSubstrate?.sheetLengthMm,
sheetWidthMm: selectedCadSubstrate?.sheetWidthMm,
suggestedPrice: selectedCadSubstrate?.suggestedPrice,
lossFactor: toOptionalNumber(cadImportForm.lossFactor),
}),
[cadImportDerivedDimensions, selectedCadSubstrate, cadImportForm.lossFactor],
)
const cadImportConnectionRoles = useMemo(
() => splitConnectionRoles(cadImportForm.connectionRoles),
[cadImportForm.connectionRoles],
@ -567,6 +613,7 @@ export default function AssetCenterClient({
setCadImportSubmitAttempted(false)
setCadImportResult(null)
setAutoInferredFields(new Set())
setCadImportDerivedDimensions({})
setCadImportModalOpen(true)
}
@ -574,6 +621,7 @@ export default function AssetCenterClient({
setCadImportModalOpen(false)
setCadImportSubmitAttempted(false)
setCadImportResult(null)
setCadImportDerivedDimensions({})
}
function prepareNextCadImport() {
@ -585,6 +633,7 @@ export default function AssetCenterClient({
setCadImportSubmitAttempted(false)
setCadImportResult(null)
setAutoInferredFields(new Set())
setCadImportDerivedDimensions({})
}
async function applyCadFileDefaults(file: File | null) {
@ -634,8 +683,18 @@ export default function AssetCenterClient({
variantName: !current.variantName.trim() ? defaults.variantName : current.variantName,
}
})
setCadImportDerivedDimensions(
cadExportData.panels?.[0]
? {
length: cadExportData.panels[0].size?.[0] ?? 0,
width: cadExportData.panels[0].size?.[1] ?? 0,
thickness: cadExportData.panels[0].size?.[2] ?? 0,
}
: {},
)
} catch (error) {
toast.warning(error instanceof Error ? error.message : '未能自动读取 ZIP 命名建议')
setCadImportDerivedDimensions({})
} finally {
setCadImportParsing(false)
}
@ -823,10 +882,31 @@ export default function AssetCenterClient({
// ---- 规格款弹窗操作 ----
function openCreateVariantModal(familyId: string) {
function openDuplicateVariantModal(variant: AssetVariantDTO) {
const dims = variant.dimensions ?? {}
const mp = variant.mountPoints?.[0]
setVariantModalMode('create')
setEditingVariantId(null)
setVariantForm(emptyVariantForm(familyId))
setVariantForm({
assetFamilyId: variant.assetFamilyId,
code: '',
name: `${variant.name} 副本`,
sizeLabel: variant.sizeLabel ?? '',
substrateId: variant.substrateId ?? '',
finishId: variant.finishId ?? '',
length: String(dims.length ?? ''),
width: String(dims.width ?? ''),
thickness: String(dims.thickness ?? ''),
modelUrl: variant.modelUrl ?? '',
mountPointName: mp?.name ?? 'origin',
mountX: String(mp?.position?.[0] ?? '0'),
mountY: String(mp?.position?.[1] ?? '0'),
mountZ: String(mp?.position?.[2] ?? '0'),
lossFactor: variant.lossFactor != null ? String(variant.lossFactor) : '1',
available: false,
inheritedFromVariantId: variant.id,
includeLegConnectorRole: false,
})
setVariantModalOpen(true)
}
@ -846,13 +926,11 @@ export default function AssetCenterClient({
length: String(dims.length ?? ''),
width: String(dims.width ?? ''),
thickness: String(dims.thickness ?? ''),
height: String(dims.height ?? ''),
mountPointName: mp?.name ?? 'origin',
mountX: String(mp?.position?.[0] ?? '0'),
mountY: String(mp?.position?.[1] ?? '0'),
mountZ: String(mp?.position?.[2] ?? '0'),
weightKg: variant.weightKg != null ? String(variant.weightKg) : '',
costPrice: variant.costPrice != null ? String(variant.costPrice) : '',
lossFactor: variant.lossFactor != null ? String(variant.lossFactor) : '1',
available: variant.status === 'AVAILABLE',
inheritedFromVariantId: '',
includeLegConnectorRole: false,
@ -888,7 +966,6 @@ export default function AssetCenterClient({
length: variantForm.length,
width: variantForm.width,
thickness: variantForm.thickness,
height: variantForm.height,
})
if (variantModalMode === 'create') {
@ -912,8 +989,7 @@ export default function AssetCenterClient({
],
},
],
weightKg: toOptionalNumber(variantForm.weightKg),
costPrice: toOptionalNumber(variantForm.costPrice),
lossFactor: toOptionalNumber(variantForm.lossFactor),
inheritedFromVariantId: variantForm.inheritedFromVariantId || null,
})
@ -922,7 +998,7 @@ export default function AssetCenterClient({
setExpandedFamilyIds((prev) => new Set(prev).add(created.assetFamilyId))
setSelectedVariantId(created.id)
closeVariantModal()
toast.success('规格款已创建')
toast.success(isDuplicateVariantMode ? '规格款副本已创建' : '规格款已创建')
})
} else {
if (!editingVariantId) return
@ -945,8 +1021,7 @@ export default function AssetCenterClient({
],
},
],
weightKg: toOptionalNumber(variantForm.weightKg),
costPrice: toOptionalNumber(variantForm.costPrice),
lossFactor: toOptionalNumber(variantForm.lossFactor),
})
startTransition(() => {
setVariants((current) =>
@ -1103,8 +1178,7 @@ export default function AssetCenterClient({
cadImportForm.assetType === 'BOARD' ? cadImportForm.substrateId || null : null,
finishId:
cadImportForm.assetType === 'HARDWARE' ? null : cadImportForm.finishId || null,
weightKg: toOptionalNumber(cadImportForm.weightKg),
costPrice: toOptionalNumber(cadImportForm.costPrice),
lossFactor: toOptionalNumber(cadImportForm.lossFactor),
file: cadImportForm.file as File,
})
@ -1263,7 +1337,7 @@ export default function AssetCenterClient({
<PlatformSplitLayout
title="资产中心"
metrics={metrics}
rightPanelClassName="w-[560px] max-w-[52vw]"
rightPanelClassName="w-[440px] max-w-[39vw]"
leftContent={
<ModelPreviewPanel
modelUrl={selectedVariant?.modelUrl || null}
@ -1417,7 +1491,7 @@ export default function AssetCenterClient({
toggleFamilyExpand(family.id)
}
}}
className="flex items-center gap-2 px-3 py-3 cursor-pointer hover:bg-apple-bg-secondary/50 transition"
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-apple-bg-secondary/50 transition"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-apple-label-tertiary transition-transform duration-200" />
@ -1448,7 +1522,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={(e) => { e.stopPropagation(); openEditFamilyModal(family) }}
className="shrink-0 rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
className="shrink-0 rounded-full p-1.5 text-apple-label-primary/60 hover:bg-apple-bg-secondary hover:text-apple-label-primary bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10 transition"
title="编辑资产族"
>
<Pencil className="h-3.5 w-3.5" />
@ -1475,7 +1549,7 @@ export default function AssetCenterClient({
}
}}
className={cn(
'flex items-center gap-2 px-4 py-2 cursor-pointer transition',
'flex items-center gap-2 px-4 py-1.5 cursor-pointer transition',
isSelected
? 'bg-apple-blue/10'
: 'hover:bg-apple-bg-secondary',
@ -1513,16 +1587,11 @@ export default function AssetCenterClient({
暂无规格款
</div>
)}
{/* 新建规格款按钮 */}
<div className="flex items-center gap-2 border-t border-apple-separator px-4 py-2">
<Button variant="tinted" size="small" onClick={() => openCreateVariantModal(family.id)}>
<Plus className="h-3.5 w-3.5" />
新建规格款
</Button>
{isImportableAssetType(family.type) ? (
<Button variant="plain" size="small" onClick={() => openCadImportModal(family)}>
<Button variant="tinted" size="small" onClick={() => openCadImportModal(family)}>
<FileUp className="h-3.5 w-3.5" />
CAD 导入
CAD 导入到该资产族
</Button>
) : null}
</div>
@ -1797,24 +1866,21 @@ export default function AssetCenterClient({
helperText="不填则按导入尺寸自动生成"
/>
<Input
label="重量 kg"
label="损耗系数"
type="number"
inputMode="decimal"
step="any"
value={cadImportForm.weightKg}
onChange={(e) => setCadImportForm((current) => ({ ...current, weightKg: e.target.value }))}
placeholder="例如 18.5"
helperText="可选,后续可在规格款里补录"
value={cadImportForm.lossFactor}
onChange={(e) => setCadImportForm((current) => ({ ...current, lossFactor: e.target.value }))}
placeholder="例如 1.15"
helperText="默认 1,表示基础损耗倍率"
/>
<Input
label="成本价"
type="number"
inputMode="decimal"
step="any"
value={cadImportForm.costPrice}
onChange={(e) => setCadImportForm((current) => ({ ...current, costPrice: e.target.value }))}
placeholder="例如 210"
helperText="可选,后续可在规格款里补录"
label="预估成本价"
value={cadImportEstimatedCostPrice != null ? String(cadImportEstimatedCostPrice) : '自动计算'}
readOnly
placeholder="选择基材并读取 ZIP 后自动计算"
helperText="根据基材整板售价和损耗系数自动估算"
/>
</div>
@ -1910,30 +1976,49 @@ export default function AssetCenterClient({
<Modal
isOpen={variantModalOpen}
onClose={closeVariantModal}
title={variantModalMode === 'create' ? '新建规格款' : '编辑规格款'}
title={variantModalMode === 'create'
? (isDuplicateVariantMode ? '另存为新规格款' : '新建规格款')
: '编辑规格款'}
size="large"
footer={
<div className="flex items-center justify-between">
{variantModalMode === 'edit' && editingVariantId && (
<Button
variant="plain"
size="small"
className="!text-apple-red"
onClick={() => {
const variant = variants.find((v) => v.id === editingVariantId)
if (variant) {
setDeleteTarget({ type: 'variant', id: variant.id, name: variant.name })
}
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除规格款
</Button>
<div className="flex items-center gap-2">
<Button
variant="plain"
size="small"
onClick={() => {
const variant = variants.find((v) => v.id === editingVariantId)
if (variant) {
openDuplicateVariantModal(variant)
}
}}
>
<Plus className="h-3.5 w-3.5 mr-1" />
另存为新规格款
</Button>
<Button
variant="plain"
size="small"
className="!text-apple-red"
onClick={() => {
const variant = variants.find((v) => v.id === editingVariantId)
if (variant) {
setDeleteTarget({ type: 'variant', id: variant.id, name: variant.name })
}
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除规格款
</Button>
</div>
)}
<div className="flex gap-2 ml-auto">
<Button variant="plain" size="small" onClick={closeVariantModal}>取消</Button>
<Button size="small" onClick={handleSaveVariant} loading={submitting}>
{variantModalMode === 'create' ? '创建规格款' : '保存修改'}
{variantModalMode === 'create'
? (isDuplicateVariantMode ? '另存为新规格款' : '创建规格款')
: '保存修改'}
</Button>
</div>
</div>
@ -2023,7 +2108,7 @@ export default function AssetCenterClient({
<Input label="长度" value={variantForm.length} onChange={(e) => setVariantForm((c) => ({ ...c, length: e.target.value }))} placeholder="1200" />
<Input label="宽度" value={variantForm.width} onChange={(e) => setVariantForm((c) => ({ ...c, width: e.target.value }))} placeholder="600" />
<Input label="厚度" value={variantForm.thickness} onChange={(e) => setVariantForm((c) => ({ ...c, thickness: e.target.value }))} placeholder="25" />
<Input label="高度" value={variantForm.height} onChange={(e) => setVariantForm((c) => ({ ...c, height: e.target.value }))} placeholder="可选" />
<Input label="损耗系数" value={variantForm.lossFactor} onChange={(e) => setVariantForm((c) => ({ ...c, lossFactor: e.target.value }))} placeholder="例如 1.15" />
</div>
</div>
<div>
@ -2036,13 +2121,15 @@ export default function AssetCenterClient({
</div>
</div>
<div className="grid gap-3 grid-cols-2">
<Input label="重量 kg" value={variantForm.weightKg} onChange={(e) => setVariantForm((c) => ({ ...c, weightKg: e.target.value }))} placeholder="可选" />
<Input label="成本价" value={variantForm.costPrice} onChange={(e) => setVariantForm((c) => ({ ...c, costPrice: e.target.value }))} placeholder="可选" />
<Input label="预估成本价" value={variantEstimatedCostPrice != null ? String(variantEstimatedCostPrice) : '自动计算'} readOnly placeholder="选择基材后自动估算" />
<Input label="基材采购价" value={selectedVariantSubstrate?.suggestedPrice != null ? String(selectedVariantSubstrate.suggestedPrice) : '未设置'} readOnly placeholder="来自基材资料" />
</div>
<Checkbox
checked={variantForm.available}
onChange={(e) => setVariantForm((c) => ({ ...c, available: e.target.checked }))}
label={variantModalMode === 'create' ? '创建后立即可用' : '标记为可用'}
label={variantModalMode === 'create'
? (isDuplicateVariantMode ? '另存后立即可用' : '创建后立即可用')
: '标记为可用'}
/>
</div>
</Modal>
@ -2146,7 +2233,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={() => openEditRoleEditor(definition)}
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
className="rounded-full p-1.5 text-apple-label-primary/60 hover:bg-apple-bg-secondary hover:text-apple-label-primary bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10 transition"
title={`编辑 ${definition.label}`}
>
<Pencil className="h-3.5 w-3.5" />
@ -2154,7 +2241,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={() => handleDeleteRoleDefinition(definition.value)}
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
className="rounded-full p-1.5 text-apple-label-primary/60 hover:bg-apple-red/10 hover:text-apple-red bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10 transition"
title={`删除 ${definition.label}`}
>
<Trash2 className="h-3.5 w-3.5" />

View File

@ -321,7 +321,7 @@ describe('AssetCenterClient workflow', () => {
)
await actor.click(screen.getByText('桌面板资产族'))
await actor.click(screen.getByRole('button', { name: 'CAD 导入' }))
await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' }))
expect(screen.getByText('导入 CAD 规格款 · 板材')).toBeInTheDocument()
expect(screen.getByText('基础资料库')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '去基础资料库补录' })).toHaveAttribute(
@ -330,7 +330,7 @@ describe('AssetCenterClient workflow', () => {
)
})
it('CAD 导入允许重量和成本价留空,提交时按 null 传给后端', async () => {
it('CAD 导入使用损耗系数并按当前口径提交导入参数', async () => {
const actor = userEvent.setup()
jest.mocked(importAssetCad).mockResolvedValue({
family: {
@ -472,7 +472,7 @@ describe('AssetCenterClient workflow', () => {
)
await actor.click(screen.getByText('140台面资产族'))
await actor.click(screen.getByRole('button', { name: 'CAD 导入' }))
await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' }))
await actor.type(screen.getByPlaceholderText('BOARD-VARIANT-001'), 'TM-001')
await actor.type(screen.getByPlaceholderText('例如 桌面板 1200 / 方管桌腿 / 十字连接件'), '140台面-A')
@ -480,6 +480,8 @@ describe('AssetCenterClient workflow', () => {
await actor.click(screen.getByRole('option', { name: '18mm 颗粒板 · PB18' }))
await actor.click(screen.getByRole('combobox', { name: '饰面 / 花色' }))
await actor.click(screen.getByRole('option', { name: '白橡木纹' }))
await actor.clear(screen.getByLabelText('损耗系数'))
await actor.type(screen.getByLabelText('损耗系数'), '1.2')
const fileInput = document.querySelector('input[type="file"][accept=".zip"]') as HTMLInputElement
const zipFile = new File(['zip-content'], '140台面.zip', { type: 'application/zip' })
@ -493,8 +495,9 @@ describe('AssetCenterClient workflow', () => {
await waitFor(() => {
expect(importAssetCad).toHaveBeenCalledWith(
expect.objectContaining({
weightKg: null,
costPrice: null,
lossFactor: 1.2,
substrateId: 'substrate-1',
finishId: 'finish-1',
file: expect.any(File),
}),
)

View File

@ -1,46 +1,21 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import MaterialLibraryClient from './MaterialLibraryClient'
import { createSubstrate, updateMaterialCategory } from '@/lib/open-product/platform-api'
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services'
import { createSubstrate } from '@/lib/open-product/platform-api'
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO } from '@/server/services'
jest.mock('next/link', () => ({
__esModule: true,
default: ({
href,
children,
...props
}: {
href: string
children: ReactNode
}) => (
<a href={href} {...props}>
{children}
</a>
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
<a href={href} {...props}>{children}</a>
),
}))
jest.mock('@/components/ui/Modal', () => ({
__esModule: true,
default: ({
isOpen,
title,
children,
footer,
}: {
isOpen: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
}) =>
isOpen ? (
<div role="dialog" aria-label={typeof title === 'string' ? title : undefined}>
<div>{title}</div>
<div>{children}</div>
<div>{footer}</div>
</div>
) : null,
default: ({ isOpen, title, children, footer }: { isOpen: boolean; title?: ReactNode; children: ReactNode; footer?: ReactNode }) =>
isOpen ? <div role="dialog" aria-label={typeof title === 'string' ? title : undefined}><div>{children}</div><div>{footer}</div></div> : null,
}))
jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
@ -50,31 +25,13 @@ jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
jest.mock('@/components/ui/Toast', () => ({
__esModule: true,
useToast: () => ({
toasts: [],
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
removeToast: jest.fn(),
}),
useToast: () => ({ toasts: [], success: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn(), removeToast: jest.fn() }),
ToastContainer: () => null,
}))
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true,
PlatformSplitLayout: ({
leftContent,
rightContent,
}: {
leftContent: ReactNode
rightContent: ReactNode
}) => (
<div>
<div>{leftContent}</div>
<div>{rightContent}</div>
</div>
),
PlatformSplitLayout: ({ leftContent, rightContent }: { leftContent: ReactNode; rightContent: ReactNode }) => <div><div>{leftContent}</div><div>{rightContent}</div></div>,
}))
jest.mock('@/lib/open-product/platform-api', () => ({
@ -82,45 +39,57 @@ jest.mock('@/lib/open-product/platform-api', () => ({
createMaterial: jest.fn(),
createMaterialCategory: jest.fn(),
createSubstrate: jest.fn(),
createSubstrateDictionary: jest.fn(),
deleteMaterial: jest.fn(),
deleteMaterialCategory: jest.fn(),
deleteSubstrate: jest.fn(),
deleteSubstrateDictionary: jest.fn(),
updateMaterial: jest.fn(),
updateMaterialCategory: jest.fn(),
updateSubstrate: jest.fn(),
updateSubstrateDictionary: jest.fn(),
}))
const mockedCreateSubstrate = jest.mocked(createSubstrate)
const mockedUpdateMaterialCategory = jest.mocked(updateMaterialCategory)
const dictionaries: SubstrateDictionaryBundleDTO = {
categories: [{ id: 'cat-board', type: 'CATEGORY', name: '密度板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
environmentGrades: [{ id: 'grade-e1', type: 'ENVIRONMENT_GRADE', name: 'E1', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
thicknessSpecs: [{ id: 'th-18', type: 'THICKNESS_SPEC', name: '18', sortOrder: 0, isActive: true, thicknessMm: 18, sheetLengthMm: 2440, sheetWidthMm: 1220, createdAt: new Date(), updatedAt: new Date() }],
surfaceOptions: [{ id: 'surface-double', type: 'SURFACE_OPTION', name: '双贴', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
systemNameOptions: [{ id: 'sys-18e1', type: 'SYSTEM_NAME_OPTION', name: '18E1级刨花板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
}
function renderClient() {
return render(
<MaterialLibraryClient
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
initialFinishes={[
{
id: 'finish-1',
name: '白橡木纹',
categoryId: 'cat-1',
categoryName: '木纹',
thumbnailUrl: null,
textureUrl: null,
color: '#CCCCCC',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
createdAt: new Date('2026-04-08T00:00:00.000Z'),
updatedAt: new Date('2026-04-08T00:00:00.000Z'),
},
] as unknown as MaterialDTO[]}
initialMaterialCategories={[
{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1, materialCount: 1 },
] as MaterialCategoryDTO[]}
initialSubstrates={[{
id: 'substrate-1',
code: 'PB18',
name: '18E1级刨花板',
description: null,
categoryId: 'cat-board',
categoryName: '密度板',
environmentGradeId: 'grade-e1',
environmentGradeName: 'E1',
thicknessSpecId: 'th-18',
thicknessSpecName: '18',
thicknessMm: 18,
sheetLengthMm: 2440,
sheetWidthMm: 1220,
surfaceOptionId: 'surface-double',
surfaceOptionName: '双贴',
systemNameOptionId: 'sys-18e1',
systemNameOptionName: '18E1级刨花板',
suggestedPrice: 180,
metadata: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
}] as SubstrateDTO[]}
initialFinishes={[] as MaterialDTO[]}
initialMaterialCategories={[] as MaterialCategoryDTO[]}
initialSubstrateDictionaries={dictionaries}
metrics={[]}
/>,
)
@ -131,97 +100,53 @@ describe('MaterialLibraryClient', () => {
jest.clearAllMocks()
})
it('新建基材后使用后端返回的完整字段刷新列表', async () => {
it('新建基材时按规则字段提交并显示自动名称', async () => {
const actor = userEvent.setup()
mockedCreateSubstrate.mockResolvedValue({
substrate: {
id: 'substrate-2',
code: 'PB25',
name: '25mm 颗粒板',
description: '后端标准说明',
densityKgM3: 720,
metadata: { source: 'server' },
name: '18E1级刨花板',
description: null,
categoryId: 'cat-board',
categoryName: '密度板',
environmentGradeId: 'grade-e1',
environmentGradeName: 'E1',
thicknessSpecId: 'th-18',
thicknessSpecName: '18',
thicknessMm: 18,
sheetLengthMm: 2440,
sheetWidthMm: 1220,
surfaceOptionId: 'surface-double',
surfaceOptionName: '双贴',
systemNameOptionId: 'sys-18e1',
systemNameOptionName: '18E1级刨花板',
suggestedPrice: 200,
metadata: null,
isActive: true,
createdAt: new Date('2026-04-08T10:00:00.000Z'),
updatedAt: new Date('2026-04-08T10:00:00.000Z'),
createdAt: new Date(),
updatedAt: new Date(),
},
})
renderClient()
await actor.click(screen.getByRole('button', { name: '新建基材' }))
const dialog = screen.getByRole('dialog', { name: '新建基材' })
const inputs = within(dialog).getAllByRole('textbox')
await actor.type(inputs[0], 'PB25')
await actor.type(inputs[1], '25mm 颗粒板')
await actor.type(inputs[2], '前端输入的旧说明')
await actor.type(within(dialog).getByPlaceholderText('例如 680'), '680')
await actor.click(within(dialog).getByRole('button', { name: '创建基材' }))
await actor.type(screen.getByLabelText('基材编码'), 'PB25')
await actor.type(screen.getByLabelText('建议售价'), '200')
await actor.click(screen.getByRole('button', { name: '创建基材' }))
await waitFor(() => {
expect(mockedCreateSubstrate).toHaveBeenCalledWith(
expect.objectContaining({
code: 'PB25',
name: '25mm 颗粒板',
categoryId: 'cat-board',
environmentGradeId: 'grade-e1',
thicknessSpecId: 'th-18',
surfaceOptionId: 'surface-double',
systemNameOptionId: 'sys-18e1',
}),
)
})
await waitFor(() => {
expect(screen.getByText('25mm 颗粒板')).toBeInTheDocument()
expect(screen.getByText('PB25')).toBeInTheDocument()
expect(screen.getByText('720 kg/m³')).toBeInTheDocument()
})
})
it('编辑材质分类后,饰面搜索会立即使用新分类名', async () => {
const actor = userEvent.setup()
mockedUpdateMaterialCategory.mockResolvedValue({
category: {
id: 'cat-1',
name: '深木纹',
icon: 'Texture',
sortOrder: 1,
materialCount: 1,
},
})
renderClient()
await actor.click(screen.getByRole('button', { name: '饰面花色' }))
const categoryTitle = screen.getAllByText('木纹').find((element) => element.tagName === 'P')
if (!categoryTitle) {
throw new Error('未找到分类标题')
}
const categoryBlock = categoryTitle.closest('div')?.parentElement
if (!categoryBlock) {
throw new Error('未找到分类区块')
}
const categoryButtons = within(categoryBlock).getAllByRole('button')
await actor.click(categoryButtons[0])
const dialog = screen.getByRole('dialog', { name: '编辑材质分类' })
const categoryNameInput = within(dialog).getByPlaceholderText('例如 木纹、石纹、纯色')
await actor.clear(categoryNameInput)
await actor.type(categoryNameInput, '深木纹')
await actor.click(within(dialog).getByRole('button', { name: '保存修改' }))
await waitFor(() => {
expect(mockedUpdateMaterialCategory).toHaveBeenCalledWith(
'cat-1',
expect.objectContaining({ name: '深木纹' }),
)
})
const searchInput = screen.getByPlaceholderText('搜索分类或饰面名称...')
await actor.clear(searchInput)
await actor.type(searchInput, '深木纹')
expect(screen.getByText('白橡木纹')).toBeInTheDocument()
})
})

View File

@ -6,10 +6,11 @@ import MaterialLibraryClient from './MaterialLibraryClient'
export default async function MaterialLibraryPage() {
await requireSession('/login')
const [substrates, finishes, materialCategories] = await Promise.all([
const [substrates, finishes, materialCategories, substrateDictionaries] = await Promise.all([
assetCenterService.listSubstrates(),
materialService.findAll({ limit: 500 }),
materialService.findAllCategories(),
assetCenterService.getSubstrateDictionaryBundle(),
])
return (
@ -17,6 +18,7 @@ export default async function MaterialLibraryPage() {
initialSubstrates={substrates}
initialFinishes={finishes}
initialMaterialCategories={materialCategories}
initialSubstrateDictionaries={substrateDictionaries}
metrics={[
{
label: '基材',

View File

@ -158,6 +158,8 @@ function createVariant(overrides?: Partial<AssetVariantDTO>): AssetVariantDTO {
},
weightKg: null,
costPrice: null,
lossFactor: null,
estimatedCostPrice: null,
inheritedFromVariantId: null,
createdBy: 'user-1',
creatorName: 'Test User',

View File

@ -130,6 +130,8 @@ function createVariant(overrides?: Partial<AssetVariantDTO>): AssetVariantDTO {
},
weightKg: null,
costPrice: null,
lossFactor: null,
estimatedCostPrice: null,
inheritedFromVariantId: null,
createdBy: 'user-1',
creatorName: 'Test User',

View File

@ -10,7 +10,10 @@ export default async function AssetCenterPage() {
const variants = await assetCenterService.listVariantsByFamilyIds(
families.map((family) => family.id),
)
const substrates = await assetCenterService.listSubstrates()
const [substrates, substrateDictionaries] = await Promise.all([
assetCenterService.listSubstrates(),
assetCenterService.getSubstrateDictionaryBundle(),
])
const finishes = await materialService.findAll({ limit: 200 })
const materialCategories = await materialService.findAllCategories()
const seriesTemplates = await seriesTemplateService.listTemplates()
@ -20,6 +23,7 @@ export default async function AssetCenterPage() {
initialFamilies={families}
initialVariants={variants}
initialSubstrates={substrates}
initialSubstrateDictionaries={substrateDictionaries}
initialSeriesTemplates={seriesTemplates}
finishes={finishes}
materialCategories={materialCategories}

View File

@ -48,7 +48,7 @@ export function AppShell({ children, user }: AppShellProps) {
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-surface)' }}>
<AppTopNav user={user} />
<AppSideNav />
<main className="ml-56 mt-14 min-h-[calc(100vh-56px)]">
<main className="ml-52 mt-14 min-h-[calc(100vh-56px)]">
{children}
</main>
</div>

View File

@ -9,9 +9,7 @@ import {
PackageOpen,
ArrowDownToLine,
Warehouse,
HelpCircle,
FolderArchive,
Plus,
UserCircle2,
} from 'lucide-react'
// 分类导航项
@ -34,7 +32,7 @@ export function AppSideNav() {
return (
<aside
className="fixed left-0 top-14 flex h-[calc(100vh-56px)] w-56 flex-col border-r p-3"
className="fixed left-0 top-14 flex h-[calc(100vh-56px)] w-52 flex-col border-r p-3"
style={{
backgroundColor: 'var(--color-surface-container-low)',
borderColor: 'var(--color-separator)',
@ -66,31 +64,16 @@ export function AppSideNav() {
{/* 底部操作区 */}
<div className="space-y-2 border-t pt-3" style={{ borderColor: 'var(--color-separator)' }}>
<Link
href="/asset-center"
className="flex w-full items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold text-white transition-opacity hover:opacity-90"
href="/users"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium transition-colors"
style={{
background: 'linear-gradient(145deg, var(--color-blue), var(--color-teal))',
color: 'var(--color-label-primary)',
backgroundColor: 'var(--color-surface-container-lowest)',
}}
>
<Plus size={14} strokeWidth={2} />
新建配置
<UserCircle2 size={16} strokeWidth={1.5} />
用户中心
</Link>
<button
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-xs transition-colors"
style={{ color: 'var(--color-label-secondary)' }}
>
<HelpCircle size={16} strokeWidth={1.5} />
技术支持
</button>
<button
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-xs transition-colors"
style={{ color: 'var(--color-label-secondary)' }}
>
<FolderArchive size={16} strokeWidth={1.5} />
归档
</button>
</div>
</aside>
)

View File

@ -126,9 +126,9 @@ export function PlatformSplitLayout({
) : null}
{/* 主体:左右分栏 */}
<main className="flex min-h-0 flex-1 overflow-hidden px-6 pb-6">
<main className="flex min-h-0 flex-1 overflow-hidden px-8 pb-6">
<section
className="flex min-h-0 flex-1 overflow-hidden rounded-lg"
className="mx-auto flex min-h-0 w-full max-w-[1680px] flex-1 overflow-hidden rounded-lg"
style={{
backgroundColor: 'var(--color-surface-container)',
}}
@ -139,7 +139,7 @@ export function PlatformSplitLayout({
{/* 右侧:检查器面板 */}
<aside
className={cn(
'w-[440px] max-w-[46vw] shrink-0 overflow-y-auto border-l p-4',
'w-[400px] max-w-[40vw] shrink-0 overflow-y-auto border-l p-4',
rightPanelClassName,
)}
style={{

View File

@ -119,14 +119,14 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
'border border-white/10',
'hover:opacity-90 hover:shadow-apple-lg hover:scale-[1.02]',
'focus:border-2 focus:border-apple-separator',
'disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none disabled:border-apple-separator/30',
'disabled:bg-apple-bg-tertiary disabled:text-apple-label-secondary disabled:shadow-none disabled:border-apple-separator/50 disabled:opacity-60',
],
tinted: [
'text-apple-label-primary bg-apple-bg-secondary/50',
'border border-apple-separator/50 backdrop-blur-md',
'hover:bg-apple-bg-tertiary hover:border-apple-separator',
'focus:border-2 focus:border-apple-separator',
'disabled:opacity-50',
'disabled:opacity-50 disabled:bg-apple-bg-secondary/30 disabled:text-apple-label-secondary',
],
gray: [
'text-apple-label-primary',
@ -134,14 +134,14 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
'border border-apple-separator/30',
'hover:bg-apple-bg-tertiary hover:border-apple-separator/60',
'focus:border-2 focus:border-apple-separator',
'disabled:opacity-50',
'disabled:opacity-50 disabled:text-apple-label-secondary',
],
plain: [
'text-apple-label-primary bg-transparent',
'text-apple-label-secondary bg-transparent',
'border border-transparent',
'hover:bg-apple-bg-secondary',
'hover:bg-apple-bg-secondary hover:text-apple-label-primary',
'focus:border-2 focus:border-apple-separator',
'disabled:opacity-40',
'disabled:opacity-40 disabled:text-apple-label-tertiary',
],
}

View File

@ -10,7 +10,7 @@
'use client'
import React from 'react'
import React, { useId } from 'react'
import { cn } from '@/lib/utils'
import { Caption1 } from './Typography'
@ -62,6 +62,8 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
disabled,
...props
}, ref) => {
const generatedId = useId()
const inputId = props.id ?? generatedId
// 确定当前状态(错误优先)
const currentState = errorText ? 'error' : state
@ -140,7 +142,10 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className={cn('space-y-2', containerClassName)}>
{/* 标签 */}
{label && (
<label className="block text-apple-md font-apple-system font-apple-medium tracking-apple-subhead leading-apple-normal text-apple-label-secondary mb-1.5">
<label
htmlFor={inputId}
className="block text-apple-md font-apple-system font-apple-medium tracking-apple-subhead leading-apple-normal text-apple-label-secondary mb-1.5"
>
{label}
{required && (
<span className="text-apple-red ml-1">*</span>
@ -160,6 +165,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{/* 输入框 */}
<input
ref={ref}
id={inputId}
disabled={disabled}
className={inputStyles}
{...props}
@ -220,6 +226,8 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
rows = 4,
...props
}, ref) => {
const generatedId = useId()
const textareaId = props.id ?? generatedId
const currentState = errorText ? 'error' : state
@ -276,7 +284,10 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<div className={cn('space-y-2', containerClassName)}>
{label && (
<label className="block text-apple-md font-apple-regular leading-apple-normal text-apple-label-primary">
<label
htmlFor={textareaId}
className="block text-apple-md font-apple-regular leading-apple-normal text-apple-label-primary"
>
{label}
{required && (
<span className="text-apple-red ml-1">*</span>
@ -286,6 +297,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea
ref={ref}
id={textareaId}
disabled={disabled}
rows={rows}
className={textareaStyles}

View File

@ -16,8 +16,8 @@ interface AssetVariantValidationInput {
mountPoints?: OpenProductMountPoint[]
substrateId?: string | null
finishId?: string | null
weightKg?: number | null
costPrice?: number | null
estimatedCostPrice?: number | null
lossFactor?: number | null
assemblyBasis?: Record<string, unknown> | null
compatibilityRules?: Record<string, unknown> | null
}
@ -30,6 +30,7 @@ interface VariantImmutableComparable {
mountPoints?: OpenProductMountPoint[]
assemblyBasis?: Record<string, unknown> | null
compatibilityRules?: Record<string, unknown> | null
lossFactor?: number | null
}
function hasDimensions(dimensions: Record<string, number>): boolean {
@ -81,11 +82,11 @@ export function validateAssetVariantForReview(input: AssetVariantValidationInput
if (!hasMountPoints(input.mountPoints)) {
errors.push('规格款缺少挂载点')
}
if (input.weightKg === null || input.weightKg === undefined) {
errors.push('规格款缺少重量')
if (input.lossFactor === null || input.lossFactor === undefined) {
errors.push('规格款缺少损耗系数')
}
if (input.costPrice === null || input.costPrice === undefined) {
errors.push('规格款缺少成本')
if (input.estimatedCostPrice === null || input.estimatedCostPrice === undefined) {
errors.push('规格款缺少预估成本价')
}
if (input.assetType === 'BOARD') {
@ -120,6 +121,7 @@ export function didImmutableVariantFieldsChange(
(current.modelUrl ?? null) !== (next.modelUrl ?? null) ||
toStableString(current.mountPoints) !== toStableString(next.mountPoints) ||
toStableString(current.assemblyBasis) !== toStableString(next.assemblyBasis) ||
toStableString(current.compatibilityRules) !== toStableString(next.compatibilityRules)
toStableString(current.compatibilityRules) !== toStableString(next.compatibilityRules) ||
(current.lossFactor ?? null) !== (next.lossFactor ?? null)
)
}

View File

@ -8,10 +8,14 @@ import type {
CreateAssetFamilyInput,
CreateAssetVariantInput,
CreateDraftGroupFromAutoCombinationInput,
CreateSubstrateDictionaryInput,
UpdateSubstrateInput,
MaterialCategoryDTO,
CreateSubstrateInput,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
SubstrateDictionaryOptionDTO,
UpdateSubstrateDictionaryInput,
DraftGroupDetailDTO,
FormalizeDraftGroupInput,
FormalizeDraftGroupResult,
@ -128,8 +132,7 @@ export interface ImportAssetCadPayload {
sizeLabel?: string | null
substrateId?: string | null
finishId?: string | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null
file: File
}
@ -149,8 +152,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise<Asse
if (input.sizeLabel) formData.append('sizeLabel', input.sizeLabel)
if (input.substrateId) formData.append('substrateId', input.substrateId)
if (input.finishId) formData.append('finishId', input.finishId)
if (input.weightKg != null) formData.append('weightKg', String(input.weightKg))
if (input.costPrice != null) formData.append('costPrice', String(input.costPrice))
if (input.lossFactor != null) formData.append('lossFactor', String(input.lossFactor))
formData.append('file', input.file)
const response = await fetch('/api/asset-center/import-cad', {
@ -194,6 +196,52 @@ export async function deleteSubstrate(id: string): Promise<void> {
})
}
export async function fetchSubstrateDictionaries(): Promise<SubstrateDictionaryBundleDTO> {
const data = await requestPlatform<{ dictionaries: SubstrateDictionaryBundleDTO }>(
'/api/asset-center/substrate-dictionaries',
)
return data.dictionaries
}
export async function createSubstrateDictionary(
input: CreateSubstrateDictionaryInput,
): Promise<SubstrateDictionaryOptionDTO> {
const data = await requestPlatform<{ option: SubstrateDictionaryOptionDTO }>(
'/api/asset-center/substrate-dictionaries',
{
method: 'POST',
body: JSON.stringify(input),
},
)
return data.option
}
export async function updateSubstrateDictionary(
id: string,
input: UpdateSubstrateDictionaryInput & { type: CreateSubstrateDictionaryInput['type'] },
): Promise<SubstrateDictionaryOptionDTO> {
const data = await requestPlatform<{ option: SubstrateDictionaryOptionDTO }>(
`/api/asset-center/substrate-dictionaries/${id}`,
{
method: 'PUT',
body: JSON.stringify(input),
},
)
return data.option
}
export async function deleteSubstrateDictionary(
id: string,
type: CreateSubstrateDictionaryInput['type'],
): Promise<void> {
await requestPlatform<{ success: boolean }>(
`/api/asset-center/substrate-dictionaries/${id}?type=${encodeURIComponent(type)}`,
{
method: 'DELETE',
},
)
}
export async function createMaterialCategory(input: {
name: string
icon?: string | null

View File

@ -0,0 +1,59 @@
export const DEFAULT_SHEET_LENGTH_MM = 2440
export const DEFAULT_SHEET_WIDTH_MM = 1220
export interface CostCalculationInput {
lengthMm?: number | null
widthMm?: number | null
sheetLengthMm?: number | null
sheetWidthMm?: number | null
suggestedPrice?: number | null
lossFactor?: number | null
}
function toFinitePositive(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
export function buildSubstrateDisplayName(params: {
thicknessLabel: string
environmentGradeLabel: string
systemNameLabel: string
}): string {
return [
params.thicknessLabel.trim(),
params.environmentGradeLabel.trim(),
params.systemNameLabel.trim(),
]
.filter(Boolean)
.join('')
}
export function buildSubstrateSummaryLabel(params: {
categoryLabel: string
surfaceLabel: string
}): string {
return [params.categoryLabel.trim(), params.surfaceLabel.trim()].filter(Boolean).join(' · ')
}
export function calculateEstimatedCost(input: CostCalculationInput): number | null {
const lengthMm = toFinitePositive(input.lengthMm)
const widthMm = toFinitePositive(input.widthMm)
const suggestedPrice = toFinitePositive(input.suggestedPrice)
if (!lengthMm || !widthMm || !suggestedPrice) {
return null
}
const sheetLengthMm = toFinitePositive(input.sheetLengthMm) ?? DEFAULT_SHEET_LENGTH_MM
const sheetWidthMm = toFinitePositive(input.sheetWidthMm) ?? DEFAULT_SHEET_WIDTH_MM
const lossFactor = toFinitePositive(input.lossFactor) ?? 1
const partArea = lengthMm * widthMm
const sheetArea = sheetLengthMm * sheetWidthMm
if (sheetArea <= 0) {
return null
}
const estimated = (partArea / sheetArea) * suggestedPrice * lossFactor
return Math.round(estimated * 100) / 100
}

View File

@ -35,6 +35,14 @@ export interface AssetVariantWithRelations extends AssetVariant {
lastModifier: { id: string; name: string } | null
}
export interface SubstrateWithRelations extends Substrate {
category: { id: string; name: string }
environmentGrade: { id: string; name: string }
thicknessSpec: { id: string; name: string; thicknessMm: number; sheetLengthMm: number; sheetWidthMm: number }
surfaceOption: { id: string; name: string }
systemNameOption: { id: string; name: string }
}
export interface SubstrateFilter {
isActive?: boolean
search?: string
@ -60,6 +68,22 @@ const assetVariantRelationInclude = {
lastModifier: { select: { id: true, name: true } },
} satisfies Prisma.AssetVariantInclude
const substrateRelationInclude = {
category: { select: { id: true, name: true } },
environmentGrade: { select: { id: true, name: true } },
thicknessSpec: {
select: {
id: true,
name: true,
thicknessMm: true,
sheetLengthMm: true,
sheetWidthMm: true,
},
},
surfaceOption: { select: { id: true, name: true } },
systemNameOption: { select: { id: true, name: true } },
} satisfies Prisma.SubstrateInclude
class AssetFamilyRepositoryClass extends BaseRepository<
AssetFamily,
Prisma.AssetFamilyCreateInput,
@ -271,6 +295,36 @@ class SubstrateRepositoryClass extends BaseRepository<
return this.prisma.substrate.findUnique({ where: { code } })
}
async findByIdWithRelations(id: string): Promise<SubstrateWithRelations | null> {
return this.prisma.substrate.findUnique({
where: { id },
include: substrateRelationInclude,
}) as Promise<SubstrateWithRelations | null>
}
async findManyWithRelations(
options: FindManyOptions<SubstrateFilter> = {},
): Promise<{ items: SubstrateWithRelations[]; total: number }> {
const { filter, pagination, sort } = options
const where = this.buildWhereClause(filter)
const orderBy = sort ? { [sort.field]: sort.order } : [{ name: 'asc' as const }]
const skip = pagination ? (pagination.page - 1) * pagination.limit : 0
const take = pagination?.limit
const [items, total] = await Promise.all([
this.prisma.substrate.findMany({
where,
orderBy,
skip,
take,
include: substrateRelationInclude,
}),
this.prisma.substrate.count({ where }),
])
return { items: items as SubstrateWithRelations[], total }
}
protected buildWhereClause(filter?: SubstrateFilter): Prisma.SubstrateWhereInput {
if (!filter) return {}

View File

@ -125,6 +125,7 @@ export type {
AssetVariantFilter,
AssetVariantWithRelations,
SubstrateFilter,
SubstrateWithRelations,
} from './asset-center.repository'
export { seriesTemplateRepository, SeriesTemplateRepositoryClass } from './series-template.repository'

View File

@ -377,8 +377,7 @@ describe('assetCadImportService', () => {
finishId: 'finish-oak',
zipFile: createZipFile('140台面.zip'),
createdBy: 'user-1',
weightKg: 18.5,
costPrice: 210,
lossFactor: 1.15,
})
expect(mockedAssetCenterService.createFamily).toHaveBeenCalledWith({

View File

@ -20,6 +20,7 @@ jest.mock('../../repositories', () => ({
},
substrateRepository: {
findById: jest.fn(),
findByIdWithRelations: jest.fn(),
},
materialRepository: {
findById: jest.fn(),
@ -49,6 +50,19 @@ describe('assetCenterService', () => {
code: 'PB18',
name: '颗粒板',
} as never)
mockedSubstrateRepository.findByIdWithRelations.mockResolvedValue({
id: 'substrate-particle-board',
code: 'PB18',
name: '颗粒板',
suggestedPrice: 180,
thicknessSpec: {
id: 'thickness-25',
name: '25',
thicknessMm: 25,
sheetLengthMm: 2440,
sheetWidthMm: 1220,
},
} as never)
mockedMaterialRepository.findById.mockResolvedValue({
id: 'finish-white-oak',
name: '白橡',
@ -147,6 +161,8 @@ describe('assetCenterService', () => {
modelUrl: '/models/board-top-1200.glb',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
businessData: { designerName: '产品 A' },
lossFactor: 1,
estimatedCostPrice: 21.79,
assetFamily: {
id: 'family-board-top',
code: 'BOARD-TOP',
@ -182,6 +198,7 @@ describe('assetCenterService', () => {
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
inheritedFromVariantId: 'variant-board-top-template',
businessData: { designerName: '产品 A' },
lossFactor: 1,
})
expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith(
@ -189,12 +206,15 @@ describe('assetCenterService', () => {
identityKey:
'BOARD|length=1200|thickness=25|width=600|substrate=substrate-particle-board|finish=finish-white-oak',
inheritedFromVariant: { connect: { id: 'variant-board-top-template' } },
lossFactor: 1,
estimatedCostPrice: expect.any(Number),
}),
)
expect(result.identityKey).toBe(
'BOARD|length=1200|thickness=25|width=600|substrate=substrate-particle-board|finish=finish-white-oak',
)
expect(result.status).toBe('DRAFT')
expect(result.lossFactor).toBe(1)
})
it('可以按 PACKAGING 和 AVAILABLE 过滤包装材料资产族', async () => {

View File

@ -293,7 +293,7 @@ describe('openPlatformDemoSeedService', () => {
mockedAssetCenterService.createSubstrate.mockResolvedValue({
id: 'substrate-pb18',
code: 'PB18',
name: '18mm 颗粒板',
name: '18E1级颗粒板',
} as never)
mockedAssetCenterService.createFamily.mockImplementation(async (input) => ({
@ -447,6 +447,8 @@ describe('openPlatformDemoSeedService', () => {
businessData: input.businessData ?? null,
weightKg: input.weightKg ?? null,
costPrice: input.costPrice ?? null,
lossFactor: input.lossFactor ?? null,
estimatedCostPrice: input.estimatedCostPrice ?? null,
inheritedFromVariantId: input.inheritedFromVariantId ?? null,
createdBy: input.createdBy ?? null,
creatorName: '开放平台录入员',
@ -493,6 +495,8 @@ describe('openPlatformDemoSeedService', () => {
businessData: input.businessData ?? null,
weightKg: input.weightKg ?? null,
costPrice: input.costPrice ?? null,
lossFactor: input.lossFactor ?? null,
estimatedCostPrice: input.estimatedCostPrice ?? null,
inheritedFromVariantId: null,
createdBy: input.updatedBy ?? null,
creatorName: '开放平台录入员',
@ -539,6 +543,8 @@ describe('openPlatformDemoSeedService', () => {
businessData: null,
weightKg: null,
costPrice: null,
lossFactor: null,
estimatedCostPrice: null,
inheritedFromVariantId: null,
createdBy: 'user-planner',
creatorName: '开放平台录入员',
@ -585,6 +591,8 @@ describe('openPlatformDemoSeedService', () => {
businessData: null,
weightKg: null,
costPrice: null,
lossFactor: null,
estimatedCostPrice: null,
inheritedFromVariantId: null,
createdBy: 'user-planner',
creatorName: '开放平台录入员',

View File

@ -25,8 +25,7 @@ interface ImportAssetCadInput {
sizeLabel?: string | null
substrateId?: string | null
finishId?: string | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null
zipFile: File
createdBy: string
}
@ -413,8 +412,7 @@ class AssetCadImportService {
cadExportData: parseResult.cadExportData,
zipFileName: input.zipFile.name,
}),
weightKg: input.weightKg ?? null,
costPrice: input.costPrice ?? null,
lossFactor: input.lossFactor ?? null,
createdBy: input.createdBy,
})

View File

@ -1,4 +1,14 @@
import { Prisma, type AssetStatus, type Substrate } from '@prisma/client'
import {
Prisma,
type AssetStatus,
type Substrate,
type SubstrateCategory,
type SubstrateEnvironmentGrade,
type SubstrateSurfaceOption,
type SubstrateSystemNameOption,
type SubstrateThicknessSpec,
} from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
assertNotSelfReview,
assertUserCanReviewAsset,
@ -9,6 +19,10 @@ import {
validateAssetVariantForReview,
} from '@/lib/open-product/asset-validation'
import { buildAssetVariantIdentityKey } from '@/lib/open-product/asset-identity'
import {
buildSubstrateDisplayName,
calculateEstimatedCost,
} from '@/lib/open-product/substrate-rules'
import type { OpenProductMountPoint } from '@/lib/open-product/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
@ -22,6 +36,7 @@ import {
type AssetVariantFilter,
type AssetFamilyWithRelations,
type AssetVariantWithRelations,
type SubstrateWithRelations,
} from '../repositories'
import type {
AssetFamilyDTO,
@ -30,13 +45,25 @@ import type {
AssetWorkflowAuditDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
CreateSubstrateDictionaryInput,
CreateSubstrateInput,
SubstrateDTO,
UpdateSubstrateInput,
SubstrateDictionaryBundleDTO,
SubstrateDictionaryOptionDTO,
SubstrateDictionaryType,
UpdateSubstrateDictionaryInput,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
UpdateSubstrateInput,
} from './asset-center.service.types'
type DictionaryRecord =
| SubstrateCategory
| SubstrateEnvironmentGrade
| SubstrateThicknessSpec
| SubstrateSurfaceOption
| SubstrateSystemNameOption
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@ -124,10 +151,11 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO {
}
}
function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
const costPrice =
record.costPrice === null || record.costPrice === undefined ? null : Number(record.costPrice)
function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null {
return value === null || value === undefined ? null : Number(value)
}
function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
return {
id: record.id,
assetFamilyId: record.assetFamilyId,
@ -155,7 +183,9 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
namingFields: isRecord(record.namingFields) ? record.namingFields : null,
businessData: isRecord(record.businessData) ? record.businessData : null,
weightKg: record.weightKg ?? null,
costPrice,
costPrice: decimalToNumber(record.costPrice),
lossFactor: record.lossFactor ?? null,
estimatedCostPrice: decimalToNumber(record.estimatedCostPrice),
inheritedFromVariantId: record.inheritedFromVariantId,
createdBy: record.createdBy,
creatorName: record.creator?.name ?? null,
@ -165,13 +195,26 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
}
}
function toSubstrateDTO(record: Substrate): SubstrateDTO {
function toSubstrateDTO(record: SubstrateWithRelations): SubstrateDTO {
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
densityKgM3: record.densityKgM3 ?? null,
categoryId: record.categoryId,
categoryName: record.category.name,
environmentGradeId: record.environmentGradeId,
environmentGradeName: record.environmentGrade.name,
thicknessSpecId: record.thicknessSpecId,
thicknessSpecName: record.thicknessSpec.name,
thicknessMm: record.thicknessSpec.thicknessMm,
sheetLengthMm: record.thicknessSpec.sheetLengthMm,
sheetWidthMm: record.thicknessSpec.sheetWidthMm,
surfaceOptionId: record.surfaceOptionId,
surfaceOptionName: record.surfaceOption.name,
systemNameOptionId: record.systemNameOptionId,
systemNameOptionName: record.systemNameOption.name,
suggestedPrice: decimalToNumber(record.suggestedPrice),
metadata: isRecord(record.metadata) ? record.metadata : null,
isActive: record.isActive,
createdAt: record.createdAt,
@ -179,6 +222,61 @@ function toSubstrateDTO(record: Substrate): SubstrateDTO {
}
}
function toDictionaryOptionDTO(
type: SubstrateDictionaryType,
record: DictionaryRecord,
): SubstrateDictionaryOptionDTO {
return {
id: record.id,
type,
name: record.name,
sortOrder: record.sortOrder,
isActive: record.isActive,
thicknessMm: 'thicknessMm' in record ? record.thicknessMm : null,
sheetLengthMm: 'sheetLengthMm' in record ? record.sheetLengthMm : null,
sheetWidthMm: 'sheetWidthMm' in record ? record.sheetWidthMm : null,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
function getDictionaryLabel(type: SubstrateDictionaryType): string {
switch (type) {
case 'CATEGORY':
return '材质主类'
case 'ENVIRONMENT_GRADE':
return '环保等级'
case 'THICKNESS_SPEC':
return '厚度规格'
case 'SURFACE_OPTION':
return '贴面类型'
case 'SYSTEM_NAME_OPTION':
return '系统名称'
}
}
function extractPlanarSize(dimensions: Record<string, number>): { lengthMm: number | null; widthMm: number | null } {
const lengthMm =
typeof dimensions.length === 'number'
? dimensions.length
: typeof dimensions.width === 'number'
? dimensions.width
: null
const widthMm =
typeof dimensions.width === 'number'
? dimensions.width
: typeof dimensions.depth === 'number'
? dimensions.depth
: typeof dimensions.height === 'number'
? dimensions.height
: null
return {
lengthMm: Number.isFinite(lengthMm ?? NaN) ? lengthMm : null,
widthMm: Number.isFinite(widthMm ?? NaN) ? widthMm : null,
}
}
async function ensureCategoryExists(categoryId: string | null | undefined): Promise<void> {
if (!categoryId) return
const category = await categoryRepository.findById(categoryId)
@ -217,6 +315,92 @@ async function ensureVariantCodeAvailable(code: string, currentId?: string): Pro
}
}
async function loadSubstrateParts(input: {
categoryId: string
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string
systemNameOptionId: string
}) {
const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] =
await Promise.all([
prisma.substrateCategory.findUnique({ where: { id: input.categoryId } }),
prisma.substrateEnvironmentGrade.findUnique({ where: { id: input.environmentGradeId } }),
prisma.substrateThicknessSpec.findUnique({ where: { id: input.thicknessSpecId } }),
prisma.substrateSurfaceOption.findUnique({ where: { id: input.surfaceOptionId } }),
prisma.substrateSystemNameOption.findUnique({ where: { id: input.systemNameOptionId } }),
])
if (!category) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '材质主类不存在')
}
if (!environmentGrade) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '环保等级不存在')
}
if (!thicknessSpec) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '厚度规格不存在')
}
if (!surfaceOption) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '贴面类型不存在')
}
if (!systemNameOption) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '系统名称不存在')
}
return {
category,
environmentGrade,
thicknessSpec,
surfaceOption,
systemNameOption,
}
}
async function buildSubstratePayload(input: {
categoryId: string
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string
systemNameOptionId: string
}) {
const parts = await loadSubstrateParts(input)
const generatedName = buildSubstrateDisplayName({
thicknessLabel: parts.thicknessSpec.name,
environmentGradeLabel: parts.environmentGrade.name,
systemNameLabel: parts.systemNameOption.name,
})
return {
generatedName,
parts,
}
}
async function computeEstimatedCostPrice(params: {
substrateId: string | null
dimensions: Record<string, number>
lossFactor: number | null
}): Promise<number | null> {
if (!params.substrateId) {
return null
}
const substrate = await substrateRepository.findByIdWithRelations(params.substrateId)
if (!substrate) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在')
}
const size = extractPlanarSize(params.dimensions)
return calculateEstimatedCost({
lengthMm: size.lengthMm,
widthMm: size.widthMm,
sheetLengthMm: substrate.thicknessSpec.sheetLengthMm,
sheetWidthMm: substrate.thicknessSpec.sheetWidthMm,
suggestedPrice: decimalToNumber(substrate.suggestedPrice),
lossFactor: params.lossFactor,
})
}
class AssetCenterService {
async listFamilies(filter?: Pick<AssetFamilyFilter, 'type' | 'status'>) {
const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined)
@ -232,12 +416,31 @@ class AssetCenterService {
}
async listSubstrates(): Promise<SubstrateDTO[]> {
const result = await substrateRepository.findMany({
const result = await substrateRepository.findManyWithRelations({
filter: { isActive: true },
})
return result.items.map((item) => toSubstrateDTO(item))
}
async getSubstrateDictionaryBundle(): Promise<SubstrateDictionaryBundleDTO> {
const [categories, environmentGrades, thicknessSpecs, surfaceOptions, systemNameOptions] =
await Promise.all([
prisma.substrateCategory.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
prisma.substrateEnvironmentGrade.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
prisma.substrateThicknessSpec.findMany({ orderBy: [{ sortOrder: 'asc' }, { thicknessMm: 'asc' }] }),
prisma.substrateSurfaceOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
prisma.substrateSystemNameOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
])
return {
categories: categories.map((item) => toDictionaryOptionDTO('CATEGORY', item)),
environmentGrades: environmentGrades.map((item) => toDictionaryOptionDTO('ENVIRONMENT_GRADE', item)),
thicknessSpecs: thicknessSpecs.map((item) => toDictionaryOptionDTO('THICKNESS_SPEC', item)),
surfaceOptions: surfaceOptions.map((item) => toDictionaryOptionDTO('SURFACE_OPTION', item)),
systemNameOptions: systemNameOptions.map((item) => toDictionaryOptionDTO('SYSTEM_NAME_OPTION', item)),
}
}
async createFamily(input: CreateAssetFamilyInput): Promise<AssetFamilyDTO> {
const code = input.code.trim()
const name = input.name.trim()
@ -309,39 +512,73 @@ class AssetCenterService {
async createSubstrate(input: CreateSubstrateInput): Promise<SubstrateDTO> {
const code = input.code.trim()
const name = input.name.trim()
if (!code || !name) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码和名称不能为空')
if (!code) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码不能为空')
}
if (await substrateRepository.findByCode(code)) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在')
}
const created = await substrateRepository.create({
code,
name,
description: trimOrNull(input.description),
densityKgM3: input.densityKgM3 ?? null,
metadata: toJsonValue(input.metadata),
isActive: true,
creator: input.createdBy ? { connect: { id: input.createdBy } } : undefined,
const payload = await buildSubstratePayload({
categoryId: input.categoryId,
environmentGradeId: input.environmentGradeId,
thicknessSpecId: input.thicknessSpecId,
surfaceOptionId: input.surfaceOptionId,
systemNameOptionId: input.systemNameOptionId,
})
const duplicate = await prisma.substrate.findFirst({
where: {
categoryId: input.categoryId,
environmentGradeId: input.environmentGradeId,
thicknessSpecId: input.thicknessSpecId,
surfaceOptionId: input.surfaceOptionId,
systemNameOptionId: input.systemNameOptionId,
},
})
if (duplicate) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '相同组合的基材规格已存在')
}
const created = await prisma.substrate.create({
data: {
code,
name: payload.generatedName,
description: trimOrNull(input.description),
category: { connect: { id: input.categoryId } },
environmentGrade: { connect: { id: input.environmentGradeId } },
thicknessSpec: { connect: { id: input.thicknessSpecId } },
surfaceOption: { connect: { id: input.surfaceOptionId } },
systemNameOption: { connect: { id: input.systemNameOptionId } },
suggestedPrice: input.suggestedPrice ?? null,
metadata: toJsonValue(input.metadata),
isActive: true,
creator: input.createdBy ? { connect: { id: input.createdBy } } : undefined,
},
include: {
category: { select: { id: true, name: true } },
environmentGrade: { select: { id: true, name: true } },
thicknessSpec: {
select: { id: true, name: true, thicknessMm: true, sheetLengthMm: true, sheetWidthMm: true },
},
surfaceOption: { select: { id: true, name: true } },
systemNameOption: { select: { id: true, name: true } },
},
}) as SubstrateWithRelations
return toSubstrateDTO(created)
}
async updateSubstrate(id: string, input: UpdateSubstrateInput): Promise<SubstrateDTO> {
const existing = await substrateRepository.findById(id)
const existing = await substrateRepository.findByIdWithRelations(id)
if (!existing) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在')
}
const nextCode = input.code?.trim() ?? existing.code
const nextName = input.name?.trim() ?? existing.name
if (!nextCode || !nextName) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码和名称不能为空')
if (!nextCode) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码不能为空')
}
const duplicateByCode = await substrateRepository.findByCode(nextCode)
@ -349,33 +586,79 @@ class AssetCenterService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在')
}
const duplicateByName = await substrateRepository.findMany({
filter: {
search: nextName,
const nextCategoryId = input.categoryId ?? existing.categoryId
const nextEnvironmentGradeId = input.environmentGradeId ?? existing.environmentGradeId
const nextThicknessSpecId = input.thicknessSpecId ?? existing.thicknessSpecId
const nextSurfaceOptionId = input.surfaceOptionId ?? existing.surfaceOptionId
const nextSystemNameOptionId = input.systemNameOptionId ?? existing.systemNameOptionId
const payload = await buildSubstratePayload({
categoryId: nextCategoryId,
environmentGradeId: nextEnvironmentGradeId,
thicknessSpecId: nextThicknessSpecId,
surfaceOptionId: nextSurfaceOptionId,
systemNameOptionId: nextSystemNameOptionId,
})
const duplicate = await prisma.substrate.findFirst({
where: {
id: { not: id },
categoryId: nextCategoryId,
environmentGradeId: nextEnvironmentGradeId,
thicknessSpecId: nextThicknessSpecId,
surfaceOptionId: nextSurfaceOptionId,
systemNameOptionId: nextSystemNameOptionId,
},
})
const matchedName = duplicateByName.items.find(
(item) => item.name === nextName && item.id !== id,
)
if (matchedName) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材名称已存在')
if (duplicate) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '相同组合的基材规格已存在')
}
const updated = await substrateRepository.update(id, {
code: nextCode,
name: nextName,
description:
input.description !== undefined ? trimOrNull(input.description) : undefined,
densityKgM3: input.densityKgM3 === undefined ? undefined : input.densityKgM3,
metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined,
isActive: input.isActive,
})
const updated = await prisma.substrate.update({
where: { id },
data: {
code: nextCode,
name: payload.generatedName,
description:
input.description !== undefined ? trimOrNull(input.description) : undefined,
category:
input.categoryId === undefined ? undefined : { connect: { id: input.categoryId } },
environmentGrade:
input.environmentGradeId === undefined
? undefined
: { connect: { id: input.environmentGradeId } },
thicknessSpec:
input.thicknessSpecId === undefined
? undefined
: { connect: { id: input.thicknessSpecId } },
surfaceOption:
input.surfaceOptionId === undefined
? undefined
: { connect: { id: input.surfaceOptionId } },
systemNameOption:
input.systemNameOptionId === undefined
? undefined
: { connect: { id: input.systemNameOptionId } },
suggestedPrice: input.suggestedPrice === undefined ? undefined : input.suggestedPrice,
metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined,
isActive: input.isActive,
},
include: {
category: { select: { id: true, name: true } },
environmentGrade: { select: { id: true, name: true } },
thicknessSpec: {
select: { id: true, name: true, thicknessMm: true, sheetLengthMm: true, sheetWidthMm: true },
},
surfaceOption: { select: { id: true, name: true } },
systemNameOption: { select: { id: true, name: true } },
},
}) as SubstrateWithRelations
return toSubstrateDTO(updated)
}
async deleteSubstrate(id: string): Promise<SubstrateDTO> {
const existing = await substrateRepository.findById(id)
const existing = await substrateRepository.findByIdWithRelations(id)
if (!existing) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在')
}
@ -384,6 +667,197 @@ class AssetCenterService {
return toSubstrateDTO(existing)
}
async createSubstrateDictionary(input: CreateSubstrateDictionaryInput): Promise<SubstrateDictionaryOptionDTO> {
const name = input.name.trim()
if (!name) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${getDictionaryLabel(input.type)}名称不能为空`)
}
const dataBase = {
name,
sortOrder: input.sortOrder ?? 0,
isActive: input.isActive ?? true,
}
try {
switch (input.type) {
case 'CATEGORY':
return toDictionaryOptionDTO(
'CATEGORY',
await prisma.substrateCategory.create({ data: dataBase }),
)
case 'ENVIRONMENT_GRADE':
return toDictionaryOptionDTO(
'ENVIRONMENT_GRADE',
await prisma.substrateEnvironmentGrade.create({ data: dataBase }),
)
case 'THICKNESS_SPEC':
if (!input.thicknessMm || input.thicknessMm <= 0) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '厚度规格必须填写厚度 mm')
}
return toDictionaryOptionDTO(
'THICKNESS_SPEC',
await prisma.substrateThicknessSpec.create({
data: {
...dataBase,
thicknessMm: input.thicknessMm,
sheetLengthMm: input.sheetLengthMm ?? 2440,
sheetWidthMm: input.sheetWidthMm ?? 1220,
},
}),
)
case 'SURFACE_OPTION':
return toDictionaryOptionDTO(
'SURFACE_OPTION',
await prisma.substrateSurfaceOption.create({ data: dataBase }),
)
case 'SYSTEM_NAME_OPTION':
return toDictionaryOptionDTO(
'SYSTEM_NAME_OPTION',
await prisma.substrateSystemNameOption.create({ data: dataBase }),
)
}
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(input.type)}名称已存在`)
}
throw error
}
}
async updateSubstrateDictionary(
type: SubstrateDictionaryType,
id: string,
input: UpdateSubstrateDictionaryInput,
): Promise<SubstrateDictionaryOptionDTO> {
try {
switch (type) {
case 'CATEGORY':
return toDictionaryOptionDTO(
'CATEGORY',
await prisma.substrateCategory.update({
where: { id },
data: {
name: input.name?.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive,
},
}),
)
case 'ENVIRONMENT_GRADE':
return toDictionaryOptionDTO(
'ENVIRONMENT_GRADE',
await prisma.substrateEnvironmentGrade.update({
where: { id },
data: {
name: input.name?.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive,
},
}),
)
case 'THICKNESS_SPEC':
return toDictionaryOptionDTO(
'THICKNESS_SPEC',
await prisma.substrateThicknessSpec.update({
where: { id },
data: {
name: input.name?.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive,
thicknessMm: input.thicknessMm ?? undefined,
sheetLengthMm: input.sheetLengthMm ?? undefined,
sheetWidthMm: input.sheetWidthMm ?? undefined,
},
}),
)
case 'SURFACE_OPTION':
return toDictionaryOptionDTO(
'SURFACE_OPTION',
await prisma.substrateSurfaceOption.update({
where: { id },
data: {
name: input.name?.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive,
},
}),
)
case 'SYSTEM_NAME_OPTION':
return toDictionaryOptionDTO(
'SYSTEM_NAME_OPTION',
await prisma.substrateSystemNameOption.update({
where: { id },
data: {
name: input.name?.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive,
},
}),
)
}
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}不存在`)
}
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}名称已存在`)
}
throw error
}
}
async deleteSubstrateDictionary(type: SubstrateDictionaryType, id: string): Promise<void> {
const whereByType: Record<SubstrateDictionaryType, Prisma.SubstrateWhereInput> = {
CATEGORY: { categoryId: id },
ENVIRONMENT_GRADE: { environmentGradeId: id },
THICKNESS_SPEC: { thicknessSpecId: id },
SURFACE_OPTION: { surfaceOptionId: id },
SYSTEM_NAME_OPTION: { systemNameOptionId: id },
}
const referenceCount = await prisma.substrate.count({ where: whereByType[type] })
if (referenceCount > 0) {
throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}已被基材使用,不能删除`)
}
try {
switch (type) {
case 'CATEGORY':
await prisma.substrateCategory.delete({ where: { id } })
return
case 'ENVIRONMENT_GRADE':
await prisma.substrateEnvironmentGrade.delete({ where: { id } })
return
case 'THICKNESS_SPEC':
await prisma.substrateThicknessSpec.delete({ where: { id } })
return
case 'SURFACE_OPTION':
await prisma.substrateSurfaceOption.delete({ where: { id } })
return
case 'SYSTEM_NAME_OPTION':
await prisma.substrateSystemNameOption.delete({ where: { id } })
return
}
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}不存在`)
}
throw error
}
}
async createVariant(input: CreateAssetVariantInput): Promise<AssetVariantDTO> {
const family = await assetFamilyRepository.findById(input.assetFamilyId)
if (!family) {
@ -414,6 +888,15 @@ class AssetCenterService {
)
}
const resolvedLossFactor = input.lossFactor ?? null
const resolvedEstimatedCostPrice =
input.estimatedCostPrice ??
await computeEstimatedCostPrice({
substrateId: input.substrateId ?? null,
dimensions: input.dimensions,
lossFactor: resolvedLossFactor,
})
const created = await assetVariantRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
code,
@ -434,6 +917,8 @@ class AssetCenterService {
businessData: toJsonValue(input.businessData),
weightKg: input.weightKg ?? null,
costPrice: input.costPrice ?? null,
lossFactor: resolvedLossFactor,
estimatedCostPrice: resolvedEstimatedCostPrice,
inheritedFromVariant: input.inheritedFromVariantId
? { connect: { id: input.inheritedFromVariantId } }
: undefined,
@ -499,6 +984,7 @@ class AssetCenterService {
input.substrateId !== undefined ? input.substrateId : existing.substrateId
const nextFinishId = input.finishId !== undefined ? input.finishId : existing.finishId
const nextModelUrl = input.modelUrl?.trim() ?? existing.modelUrl
const nextLossFactor = input.lossFactor !== undefined ? input.lossFactor : existing.lossFactor
if (
existing.status === 'AVAILABLE' &&
@ -517,6 +1003,7 @@ class AssetCenterService {
compatibilityRules: isRecord(existing.compatibilityRules)
? existing.compatibilityRules
: null,
lossFactor: existing.lossFactor ?? null,
},
{
dimensions: nextDimensions,
@ -526,6 +1013,7 @@ class AssetCenterService {
mountPoints: nextMountPoints,
assemblyBasis: nextAssemblyBasis,
compatibilityRules: nextCompatibilityRules,
lossFactor: nextLossFactor ?? null,
},
)
) {
@ -550,6 +1038,15 @@ class AssetCenterService {
)
}
const resolvedEstimatedCostPrice =
input.estimatedCostPrice !== undefined
? input.estimatedCostPrice
: await computeEstimatedCostPrice({
substrateId: nextSubstrateId ?? null,
dimensions: nextDimensions,
lossFactor: nextLossFactor ?? null,
})
const updated = await assetVariantRepository.updateWithRelations(id, {
assetFamily:
input.assetFamilyId === undefined
@ -596,6 +1093,9 @@ class AssetCenterService {
input.businessData !== undefined ? toJsonValue(input.businessData) : undefined,
weightKg: input.weightKg === undefined ? undefined : input.weightKg,
costPrice: input.costPrice === undefined ? undefined : input.costPrice,
lossFactor: input.lossFactor === undefined ? undefined : input.lossFactor,
estimatedCostPrice:
input.estimatedCostPrice === undefined ? resolvedEstimatedCostPrice : input.estimatedCostPrice,
lastModifier: input.updatedBy ? { connect: { id: input.updatedBy } } : undefined,
})
@ -728,8 +1228,11 @@ class AssetCenterService {
: [],
substrateId: existing.substrateId,
finishId: existing.finishId,
weightKg: existing.weightKg,
costPrice: existing.costPrice === null || existing.costPrice === undefined ? null : Number(existing.costPrice),
estimatedCostPrice:
existing.estimatedCostPrice === null || existing.estimatedCostPrice === undefined
? null
: Number(existing.estimatedCostPrice),
lossFactor: existing.lossFactor ?? null,
assemblyBasis: isRecord(existing.assemblyBasis) ? existing.assemblyBasis : null,
compatibilityRules: isRecord(existing.compatibilityRules)
? existing.compatibilityRules
@ -773,8 +1276,11 @@ class AssetCenterService {
: [],
substrateId: existing.substrateId,
finishId: existing.finishId,
weightKg: existing.weightKg,
costPrice: existing.costPrice === null || existing.costPrice === undefined ? null : Number(existing.costPrice),
estimatedCostPrice:
existing.estimatedCostPrice === null || existing.estimatedCostPrice === undefined
? null
: Number(existing.estimatedCostPrice),
lossFactor: existing.lossFactor ?? null,
assemblyBasis: isRecord(existing.assemblyBasis) ? existing.assemblyBasis : null,
compatibilityRules: isRecord(existing.compatibilityRules)
? existing.compatibilityRules
@ -837,9 +1343,14 @@ export type {
AssetVariantDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
CreateSubstrateDictionaryInput,
CreateSubstrateInput,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
SubstrateDictionaryOptionDTO,
SubstrateDictionaryType,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
UpdateSubstrateDictionaryInput,
UpdateSubstrateInput,
} from './asset-center.service.types'

View File

@ -61,6 +61,8 @@ export interface AssetVariantDTO {
businessData: Record<string, unknown> | null
weightKg: number | null
costPrice: number | null
lossFactor: number | null
estimatedCostPrice: number | null
inheritedFromVariantId: string | null
createdBy: string | null
creatorName: string | null
@ -74,13 +76,54 @@ export interface SubstrateDTO {
code: string
name: string
description: string | null
densityKgM3: number | null
categoryId: string
categoryName: string
environmentGradeId: string
environmentGradeName: string
thicknessSpecId: string
thicknessSpecName: string
thicknessMm: number
sheetLengthMm: number
sheetWidthMm: number
surfaceOptionId: string
surfaceOptionName: string
systemNameOptionId: string
systemNameOptionName: string
suggestedPrice: number | null
metadata: Record<string, unknown> | null
isActive: boolean
createdAt: Date
updatedAt: Date
}
export type SubstrateDictionaryType =
| 'CATEGORY'
| 'ENVIRONMENT_GRADE'
| 'THICKNESS_SPEC'
| 'SURFACE_OPTION'
| 'SYSTEM_NAME_OPTION'
export interface SubstrateDictionaryOptionDTO {
id: string
type: SubstrateDictionaryType
name: string
sortOrder: number
isActive: boolean
thicknessMm?: number | null
sheetLengthMm?: number | null
sheetWidthMm?: number | null
createdAt: Date
updatedAt: Date
}
export interface SubstrateDictionaryBundleDTO {
categories: SubstrateDictionaryOptionDTO[]
environmentGrades: SubstrateDictionaryOptionDTO[]
thicknessSpecs: SubstrateDictionaryOptionDTO[]
surfaceOptions: SubstrateDictionaryOptionDTO[]
systemNameOptions: SubstrateDictionaryOptionDTO[]
}
export interface CreateAssetFamilyInput {
type: AssetType
code: string
@ -124,6 +167,8 @@ export interface CreateAssetVariantInput {
businessData?: Record<string, unknown> | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null
estimatedCostPrice?: number | null
inheritedFromVariantId?: string | null
createdBy?: string | null
}
@ -146,27 +191,56 @@ export interface UpdateAssetVariantInput {
businessData?: Record<string, unknown> | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null
estimatedCostPrice?: number | null
updatedBy?: string | null
}
export interface CreateSubstrateInput {
code: string
name: string
description?: string | null
densityKgM3?: number | null
categoryId: string
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string
systemNameOptionId: string
suggestedPrice?: number | null
metadata?: Record<string, unknown> | null
createdBy?: string | null
}
export interface UpdateSubstrateInput {
code?: string
name?: string
description?: string | null
densityKgM3?: number | null
categoryId?: string
environmentGradeId?: string
thicknessSpecId?: string
surfaceOptionId?: string
systemNameOptionId?: string
suggestedPrice?: number | null
metadata?: Record<string, unknown> | null
isActive?: boolean
}
export interface CreateSubstrateDictionaryInput {
type: SubstrateDictionaryType
name: string
sortOrder?: number
isActive?: boolean
thicknessMm?: number | null
sheetLengthMm?: number | null
sheetWidthMm?: number | null
}
export interface UpdateSubstrateDictionaryInput {
name?: string
sortOrder?: number
isActive?: boolean
thicknessMm?: number | null
sheetLengthMm?: number | null
sheetWidthMm?: number | null
}
export interface AssetReviewActionInput {
reason?: string | null
}

View File

@ -159,10 +159,15 @@ export type {
AssetVariantDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
CreateSubstrateDictionaryInput,
CreateSubstrateInput,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
SubstrateDictionaryOptionDTO,
SubstrateDictionaryType,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
UpdateSubstrateDictionaryInput,
UpdateSubstrateInput,
} from './asset-center.service'
export { assetCadImportService, AssetCadImportService } from './asset-cad-import.service'

View File

@ -315,11 +315,43 @@ async function ensureSubstrate(creatorId: string) {
return existing
}
const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] = await Promise.all([
prisma.substrateCategory.upsert({
where: { name: '颗粒板' },
update: {},
create: { name: '颗粒板', sortOrder: 0, isActive: true },
}),
prisma.substrateEnvironmentGrade.upsert({
where: { name: 'E1' },
update: {},
create: { name: 'E1', sortOrder: 0, isActive: true },
}),
prisma.substrateThicknessSpec.upsert({
where: { name: '18' },
update: {},
create: { name: '18', thicknessMm: 18, sheetLengthMm: 2440, sheetWidthMm: 1220, sortOrder: 0, isActive: true },
}),
prisma.substrateSurfaceOption.upsert({
where: { name: '双贴' },
update: {},
create: { name: '双贴', sortOrder: 0, isActive: true },
}),
prisma.substrateSystemNameOption.upsert({
where: { name: '18E1级颗粒板' },
update: {},
create: { name: '18E1级颗粒板', sortOrder: 0, isActive: true },
}),
])
return assetCenterService.createSubstrate({
code: 'PB18',
name: '18mm 颗粒板',
description: '开放平台演示基材',
densityKgM3: 680,
categoryId: category.id,
environmentGradeId: environmentGrade.id,
thicknessSpecId: thicknessSpec.id,
surfaceOptionId: surfaceOption.id,
systemNameOptionId: systemNameOption.id,
suggestedPrice: 180,
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
materialGroup: 'board-core',