feat(asset-center): 重构基材规格与CAD规格款口径
This commit is contained in:
parent
f0bf6c09a2
commit
6723c8f5f7
@ -13,20 +13,8 @@
|
||||
|
||||
## 当前未完成项
|
||||
|
||||
### 待联调确认 (全栈 / 后端 Agent 负责)
|
||||
|
||||
- [ ] **资产中心资产族新流程人工验收**
|
||||
归属:`联调确认`
|
||||
说明:确认资产中心现在已改成"先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录"的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
|
||||
|
||||
- [ ] **资产族品类系列 + 中文连接角色维护人工验收**
|
||||
归属:`联调确认`
|
||||
说明:已改成在资产族弹窗内选择品类系列,并维护中文连接角色(支持新增 / 编辑 / 删除)。需重点确认:系列切换后推荐角色是否合理、旧数据回显是否稳定、保存后列表和 CAD 导入提示是否一致。
|
||||
### 待前端修正 (前端 UI Agent 负责)
|
||||
|
||||
- [ ] **包材资料库页面视觉联调**
|
||||
归属:`联调确认`
|
||||
归属:`前端负责`
|
||||
说明:确认该页面视觉是否已统一到主线风格。
|
||||
|
||||
- [ ] **基础资料库 CRUD 联调**
|
||||
归属:`联调确认`
|
||||
说明:确认 `基础资料库` 页的基材新增/编辑/删除、材质分类新增/编辑/删除、饰面新增/编辑/删除全部可用;删除后回到资产中心,CAD 导入弹窗、规格款弹窗和 3D 右键快捷菜单都能读到最新数据。
|
||||
|
||||
@ -42,6 +42,21 @@
|
||||
|
||||
## 3. 默认操作步骤
|
||||
|
||||
先加一个固定前置动作:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
如果这里不是空的,先把当前改动处理好再同步主线:
|
||||
|
||||
- 要保留并提交:先提交到你当前分支
|
||||
- 只是临时改动:先暂存(stash)再继续
|
||||
|
||||
不要在“有一堆未提交改动”时直接 `merge main` 或 `rebase main`。
|
||||
|
||||
---
|
||||
|
||||
如果你现在在长期共享分支 `codex/xuxiake-packaging`,就把下面这 5 行命令原样交给 Codex 执行:
|
||||
|
||||
```bash
|
||||
|
||||
@ -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` 承接链,收口时额外确认:有没有把包装或仓库逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -54,9 +54,9 @@
|
||||
|
||||
### 包装协作
|
||||
|
||||
- 当前包装由同事负责
|
||||
- 主线只保证包装在草稿组和 `PreSku` 上有稳定承接口
|
||||
- 不把包装重新做成独立顶层主入口
|
||||
- 当前包装与轻仓库由同事负责
|
||||
- 主线只保证包装在草稿组和 `PreSku` 上有稳定承接口,并保证轻仓库承接正式入库结果
|
||||
- 不把包装或轻仓库重新做成独立顶层主入口
|
||||
|
||||
## 已落地的基础能力
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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: '基材',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
59
furniture-3d-system/src/lib/open-product/substrate-rules.ts
Normal file
59
furniture-3d-system/src/lib/open-product/substrate-rules.ts
Normal 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
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
|
||||
@ -125,6 +125,7 @@ export type {
|
||||
AssetVariantFilter,
|
||||
AssetVariantWithRelations,
|
||||
SubstrateFilter,
|
||||
SubstrateWithRelations,
|
||||
} from './asset-center.repository'
|
||||
|
||||
export { seriesTemplateRepository, SeriesTemplateRepositoryClass } from './series-template.repository'
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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: '开放平台录入员',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user