From f0d3f5daae548e80ba3445f755d224c2bcb92464 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 2 Jun 2026 10:57:38 +0800 Subject: [PATCH] chore(data-foundation): sync xuxiake mainline and stabilize master data --- furniture-3d-system/.gitignore | 4 + furniture-3d-system/docs/DEPLOYMENT.md | 25 +- .../docs/workflow/生产部署入口说明.md | 8 +- .../包装方案增强/包装方案增强-非程序员操作手册.md | 4 + furniture-3d-system/docs/飞牛OS部署指南.md | 8 +- furniture-3d-system/package.json | 41 +- furniture-3d-system/prisma.config.ts | 18 +- .../migration.sql | 8 +- .../add_packaging_plan/migration.sql | 20 +- furniture-3d-system/prisma/schema.prisma | 942 +++++++---- furniture-3d-system/prisma/seed.ts | 62 +- furniture-3d-system/scripts/add-more-skus.ts | 39 +- .../backfill-asset-variant-thumbnails.ts | 86 +- .../scripts/backfill-sku-structural-data.ts | 140 +- .../cleanup-legacy-product-structure.ts | 72 +- .../scripts/create-demo-data.js | 33 +- .../install-employee-directory-sync-timer.sh | 2 +- .../scripts/performance-test.ts | 28 +- .../scripts/seed-open-platform-demo.ts | 38 +- .../scripts/seed-test-products.ts | 78 +- .../scripts/sync-board-material-library.ts | 82 +- .../scripts/sync-employee-directory.ts | 9 +- .../scripts/sync-jst-sku-stock-products.ts | 116 +- .../scripts/sync-standard-finishes.ts | 75 +- .../scripts/update-demo-models.js | 33 +- .../scripts/update-model-urls.ts | 102 +- .../scripts/update-test-data.ts | 73 +- .../app/__tests__/draft-groups-page.test.tsx | 6 + .../src/app/__tests__/intake-page.test.tsx | 175 +- .../app/api/approval-workflows/route.test.ts | 2 +- .../api/intake/non-integrated/[id]/route.ts | 10 +- .../src/app/api/platform/schemas.ts | 32 +- .../src/app/api/trash/products/[id]/route.ts | 43 +- .../src/app/api/trash/skus/[id]/route.ts | 43 +- .../draft-groups/DraftGroupsFormalClient.tsx | 54 +- .../src/app/home-client.types.ts | 3 +- .../src/app/home/home-client.types.ts | 3 +- .../src/app/intake/AccessoryLibraryPanel.tsx | 148 +- .../app/intake/AlphabetLibraryPanel.test.tsx | 335 +++- .../src/app/intake/AlphabetLibraryPanel.tsx | 166 +- .../app/intake/EdgeBandingLibraryPanel.tsx | 280 +++- .../src/app/intake/IntakeClient.test.tsx | 284 ++-- .../src/app/intake/IntakeClient.tsx | 143 +- .../intake/PackagingMaterialLibraryPanel.tsx | 7 +- .../app/intake/RawMaterialLibraryPanel.tsx | 46 +- .../app/intake/SpuRuleLibraryWorkspace.tsx | 144 +- .../src/app/intake/SteelFrameLibraryPanel.tsx | 198 ++- .../integratedDraftGroupAdapter.test.ts | 170 +- .../integrated/integratedDraftGroupAdapter.ts | 79 +- .../integrated/integratedSeriesLoader.test.ts | 17 + .../NonIntegratedDetailClient.test.tsx | 433 ++--- .../NonIntegratedDetailClient.tsx | 281 ++-- .../intake/non-integrated/nonIntegratedApi.ts | 11 +- .../non-integrated/nonIntegratedStorage.ts | 71 +- furniture-3d-system/src/app/intake/page.tsx | 80 +- .../src/app/intake/productPatentStorage.ts | 154 +- .../src/app/intake/seriesComponentDetails.ts | 11 +- .../src/app/intake/spuRuleLibraryLogic.ts | 191 ++- .../[id]/ProductDetailClient.workspace.tsx | 32 +- .../src/app/products/[id]/page.tsx | 79 +- .../[id]/product-detail-client.types.ts | 2 +- .../[skuId]/SKUDetailClient.workspace.tsx | 6 +- .../[skuId]/components/SKUReadOnlySidebar.tsx | 9 +- .../[skuId]/components/SKUSwitchDrawer.tsx | 9 +- .../app/products/[id]/skus/[skuId]/page.tsx | 39 +- .../skus/[skuId]/sku-detail-client.types.ts | 6 +- .../src/app/share/[token]/SharePageClient.tsx | 44 +- .../src/app/share/[token]/page.tsx | 8 +- .../src/app/trash/TrashClient.tsx | 2 +- .../src/app/warehouse/page.tsx | 32 +- .../src/components/products/EditSKUModal.tsx | 103 +- .../src/components/ui/ProductListItem.tsx | 3 +- .../src/lib/__tests__/sku-cad-data.test.ts | 17 + .../src/lib/accessory-library.test.ts | 30 + .../src/lib/accessory-library.ts | 119 +- .../src/lib/edge-banding-library.test.ts | 29 + .../src/lib/edge-banding-library.ts | 102 +- .../lib/non-integrated-bom-steel-materials.ts | 9 +- .../__tests__/platform-view.test.ts | 28 + .../src/lib/open-product/asset-coding.ts | 4 + .../src/lib/open-product/platform-view.ts | 40 + .../lib/packaging-material-library.test.ts | 30 + .../src/lib/packaging-material-library.ts | 82 +- furniture-3d-system/src/lib/permissions.ts | 4 +- .../src/lib/planning/parsers.ts | 41 +- furniture-3d-system/src/lib/sku-cad-data.ts | 30 + .../src/lib/steel-frame-library.test.ts | 100 +- .../src/lib/steel-frame-library.ts | 150 +- .../src/server/dto/product.dto.ts | 82 +- furniture-3d-system/src/server/dto/sku.dto.ts | 42 +- ...-non-integrated-product.repository.test.ts | 127 ++ .../__tests__/product.repository.test.ts | 379 ++++- .../repositories/asset-center.repository.ts | 11 +- .../common-component.repository.ts | 5 +- .../repositories/draft-group.repository.ts | 51 +- .../employee-directory.repository.ts | 142 +- .../repositories/feedback.repository.ts | 35 +- .../repositories/hardware.repository.ts | 5 +- ...ntake-non-integrated-product.repository.ts | 195 ++- .../src/server/repositories/log.repository.ts | 31 +- .../repositories/material.repository.ts | 5 +- .../repositories/planning.repository.ts | 5 +- .../repositories/product-upload.repository.ts | 11 +- .../server/repositories/product.repository.ts | 227 +-- .../server/repositories/share.repository.ts | 4 + .../src/server/repositories/sku.repository.ts | 19 +- .../repositories/warehouse.repository.ts | 20 +- .../asset-cad-import.service.test.ts | 80 + .../__tests__/asset-sku-sync.service.test.ts | 103 +- .../__tests__/draft-group.service.test.ts | 337 +++- .../employee-directory-sync.service.test.ts | 44 +- ...ake-non-integrated-product.service.test.ts | 963 ++++++++++- ...ntake-non-integrated-scope.service.test.ts | 73 + .../services/__tests__/intake.service.test.ts | 653 +++++++- .../__tests__/material.service.test.ts | 145 ++ .../__tests__/planning.service.test.ts | 109 +- .../product-write.service.permission.test.ts | 43 + .../services/__tests__/share.service.test.ts | 65 +- .../__tests__/sku-command.service.test.ts | 404 ++++- .../__tests__/warehouse.service.test.ts | 178 +- .../services/approval-workflow.service.ts | 228 ++- .../services/asset-cad-import.service.ts | 16 +- .../server/services/asset-center.service.ts | 111 +- .../src/server/services/asset-sku-snapshot.ts | 177 +- .../server/services/asset-sku-sync.service.ts | 24 +- .../assisted-assembly-ai-render.service.ts | 44 +- .../services/common-component.service.ts | 23 +- .../src/server/services/draft-group.mapper.ts | 63 +- .../server/services/draft-group.service.ts | 197 ++- .../services/draft-group.service.types.ts | 19 + .../employee-directory-sync.service.ts | 57 +- .../services/feedback-workflow.helper.ts | 68 +- .../services/feedback-workflow.service.ts | 90 +- .../services/hardware-matcher.runtime.ts | 16 +- .../src/server/services/hardware-write-ops.ts | 62 +- .../services/hardware.service.container.ts | 47 +- .../src/server/services/hole-crud.service.ts | 171 +- .../src/server/services/hole.service.types.ts | 4 +- .../src/server/services/index.ts | 16 + .../services/intake-contract-docx.service.ts | 5 + ...intake-node-permission-settings.service.ts | 81 +- .../intake-non-integrated-product.service.ts | 1431 ++++++++++++++--- .../intake-non-integrated-scope.service.ts | 64 +- .../src/server/services/intake.service.ts | 342 +++- .../services/jst-sku-stock-sync.service.ts | 101 +- .../src/server/services/logger.service.ts | 24 +- .../src/server/services/material.service.ts | 72 +- .../open-platform-demo-seed.service.ts | 3 +- .../services/packaging-plan-calculator.ts | 65 + .../src/server/services/packaging.service.ts | 70 +- .../services/panel-validation.service.ts | 123 ++ .../src/server/services/panel.service.ts | 65 +- .../services/planning-validation.service.ts | 114 ++ .../src/server/services/planning.service.ts | 122 +- .../services/pre-sku-packaging.service.ts | 22 +- .../services/product-cad-upload.service.ts | 11 +- .../services/product-collaborator.service.ts | 80 +- .../services/product-hardware.service.ts | 108 +- .../server/services/product-upload.service.ts | 19 +- .../server/services/product-write.service.ts | 71 +- .../services/provider-config.service.ts | 101 +- .../src/server/services/share.service.ts | 32 +- .../server/services/sku-cad-import.helper.ts | 5 +- .../server/services/sku-command.service.ts | 204 ++- .../src/server/services/sku-query.service.ts | 39 +- .../server/services/sku-soft-delete.helper.ts | 204 ++- .../src/server/services/sku-upload.service.ts | 3 +- .../src/server/services/sku.service.types.ts | 1 + .../server/services/system-config.service.ts | 56 +- .../src/server/services/user.service.ts | 100 +- .../src/server/services/warehouse.service.ts | 174 +- .../server/validators/sku.validator.test.ts | 14 + .../src/server/validators/sku.validator.ts | 116 +- scripts/deploy-feiniu-manual.md | 3 +- scripts/prepare-deploy-package.ps1 | 6 +- scripts/部署到飞牛OS-快速指南.md | 3 +- 176 files changed, 13356 insertions(+), 3679 deletions(-) diff --git a/furniture-3d-system/.gitignore b/furniture-3d-system/.gitignore index fd957313..4801205e 100644 --- a/furniture-3d-system/.gitignore +++ b/furniture-3d-system/.gitignore @@ -42,8 +42,12 @@ yarn-error.log* next-env.d.ts /src/generated/prisma +.database-backups/ +.master-data-audits/ +.master-data-cleanup/ # local workspace artifacts +.codex-runtime/ .codex-logs/ _logs/ output/ diff --git a/furniture-3d-system/docs/DEPLOYMENT.md b/furniture-3d-system/docs/DEPLOYMENT.md index 8c301f0c..f7178d61 100644 --- a/furniture-3d-system/docs/DEPLOYMENT.md +++ b/furniture-3d-system/docs/DEPLOYMENT.md @@ -59,10 +59,13 @@ docker-compose -f docker-compose.lightweight.yml up -d ### 4. 初始化数据库 ```bash # 进入应用容器执行迁移 -docker exec -it furniture3d-app npx prisma db push +docker exec -it furniture3d-app npx prisma migrate deploy # 或在本地执行(需要数据库可访问) -npx prisma db push +npx prisma migrate deploy + +# 迁移后验证主数据地基 +npm run db:verify-master-data:all:strict ``` ### 5. 访问系统 @@ -134,19 +137,22 @@ JWT_EXPIRES_IN=7d ### 首次部署 ```bash -# 推送 schema 到数据库 -npx prisma db push +# 应用 Prisma migration,保留 _prisma_migrations 历史 +npx prisma migrate deploy # 生成 Prisma Client npx prisma generate + +# 验证主数据迁移、视图、约束和 JSON 边界 +npm run db:verify-master-data:all:strict ``` ### 后续更新(Schema 变更) ```bash -# 开发环境:直接推送 -npx prisma db push +# 仅本地临时开发环境可用;脚本会拒绝 production、非本机库和疑似生产库名 +npm run prisma:push -# 生产环境:使用迁移(推荐) +# 生产环境:必须使用迁移 npx prisma migrate deploy ``` @@ -272,10 +278,13 @@ docker-compose -f docker-compose.prod.yml build app docker-compose -f docker-compose.prod.yml up -d app # 执行数据库迁移(如有) -docker exec -it furniture3d-app npx prisma db push +docker exec -it furniture3d-app npx prisma migrate deploy +docker exec -it furniture3d-app npm run db:verify-master-data:all:strict ``` ### 重要提示 - 更新前请先备份数据库 - 测试环境验证后再更新生产环境 - 保留旧版本镜像以便回滚 +- 生产/准生产禁止用 `prisma db push` 替代迁移;否则会绕过 `_prisma_migrations`,后续无法证明数据库结构来源。 +- 仓库内 `npm run prisma:push` 只作为本地开发保护入口:默认只允许 `localhost / 127.0.0.1 / ::1`,拒绝 `NODE_ENV=production`、`VERCEL_ENV=production` 和疑似生产库名。 diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md index 797a71fd..543a454e 100644 --- a/furniture-3d-system/docs/workflow/生产部署入口说明.md +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -15,15 +15,15 @@ ## 本仓库部署脚本 -- `scripts/deploy-yydd-prod-from-git.ps1` -- `scripts/create-yydd-prod-deploy.ps1` +- 外层仓库脚本:`../scripts/create-yydd-prod-deploy.ps1` -执行部署或生产核查前,优先读取上面两个脚本的默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。 +执行部署或生产核查前,优先读取上面脚本的默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。该脚本生成的远端发布脚本会执行 `npx prisma migrate deploy`,因此生产发布前必须先确认迁移目录、迁移历史和主数据 strict 巡检口径没有分叉。 ## 花名册同步 - 花名册同步属于当前 `furniture-3d-system` 自有能力,不依赖 `shujuku2` 或旧入口机。 -- 项目命令:`npm run sync:employee-directory` +- 项目默认命令:`npm run sync:employee-directory` 只做 dry-run,不提交同步载荷。 +- 真实同步命令:`npm run sync:employee-directory:apply`,生产 systemd 脚本必须显式使用 `--apply`。 - 生产定时器安装脚本:`furniture-3d-system/scripts/install-employee-directory-sync-timer.sh` - 生产 systemd 名称:`furniture-employee-directory-sync.service` / `furniture-employee-directory-sync.timer` - 定时频率:每天 `03:00` 和 `15:00`。 diff --git a/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md b/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md index 50b7def9..46081722 100644 --- a/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md +++ b/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md @@ -142,6 +142,8 @@ npm run prisma:generate npm run prisma:push ``` +`npm run prisma:push` 只允许本地开发库使用。生产或准生产数据库必须走 `npx prisma migrate deploy`,并在迁移后执行 `npm run db:verify-master-data:all:strict`。 + ### 方案 C:如果本地几乎没有演示数据 可以先试: @@ -496,6 +498,8 @@ npm run prisma:generate npm run prisma:push ``` +这里的 `npm run prisma:push` 仍然只用于本地开发库;如果你面对的是服务器、生产库或准生产库,停下来改用迁移流程,不要直接推 schema。 + 然后重启 `npm run dev`。 ### 情况 3:页面没变化 diff --git a/furniture-3d-system/docs/飞牛OS部署指南.md b/furniture-3d-system/docs/飞牛OS部署指南.md index 57a52a06..d0b6befe 100644 --- a/furniture-3d-system/docs/飞牛OS部署指南.md +++ b/furniture-3d-system/docs/飞牛OS部署指南.md @@ -230,7 +230,8 @@ curl http://localhost:3000/api/health 2. **数据库初始化** ```bash # 如果需要初始化数据库,在服务器上执行: - docker-compose -f docker-compose.prod.yml exec app npx prisma db push + docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy + docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict docker-compose -f docker-compose.prod.yml exec app npx prisma db seed ``` @@ -290,8 +291,9 @@ docker stats furniture3d-app # 进入PostgreSQL容器 docker-compose -f docker-compose.prod.yml exec postgres psql -U furniture -d furniture_db -# 执行数据库迁移 -docker-compose -f docker-compose.prod.yml exec app npx prisma db push +# 执行数据库迁移;生产/准生产不要使用 prisma db push +docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy +docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict # 执行数据库种子 docker-compose -f docker-compose.prod.yml exec app npx prisma db seed diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index be05cfcb..48bd6d47 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -14,8 +14,14 @@ "clean:local": "node scripts/cleanup-local-artifacts.js", "prisma:generate": "prisma generate", "prisma:check-client-sync": "node scripts/check-prisma-client-sync.js", - "prisma:push": "prisma db push", + "prisma:push": "node scripts/prisma-db-push-local.js", "prisma:studio": "prisma studio", + "db:verify-master-data": "node scripts/verify-master-data-foundation.js", + "db:verify-master-data:audit": "node scripts/verify-master-data-foundation.js --output .master-data-audits/foundation-hard-ready-gate-current.json --summary-output .master-data-audits/foundation-hard-ready-gate-current.summary.json --summary-stdout", + "db:verify-master-data:strict": "node scripts/verify-master-data-foundation.js --require-applied", + "db:verify-master-data:migration-status": "node scripts/verify-master-data-foundation.js --read-only-migration-status", + "db:cleanup-master-data": "node scripts/apply-master-data-cleanup-plan.js", + "db:cleanup-master-data:validate": "node scripts/apply-master-data-cleanup-plan.js --validate-plan", "prisma:seed": "tsx prisma/seed.ts", "open-platform:seed": "tsx scripts/seed-open-platform-demo.ts", "type-check": "node scripts/run-type-check.js", @@ -24,10 +30,41 @@ "demo:update": "node scripts/update-demo-models.js", "demo:refresh": "npm run prisma:push && npm run demo:update", "thumbnails:backfill": "tsx scripts/backfill-asset-variant-thumbnails.ts", + "thumbnails:backfill:apply": "tsx scripts/backfill-asset-variant-thumbnails.ts --apply", "materials:sync-textures": "tsx scripts/sync-material-textures.ts", "materials:sync-standard": "tsx scripts/sync-standard-finishes.ts", + "materials:sync-standard:apply": "tsx scripts/sync-standard-finishes.ts --apply", "sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts", - "sync:employee-directory": "tsx scripts/sync-employee-directory.ts" + "sync:employee-directory": "tsx scripts/sync-employee-directory.ts", + "sync:employee-directory:apply": "tsx scripts/sync-employee-directory.ts --apply", + "db:verify-master-data:truth": "node scripts/verify-master-data-truth-sources.js", + "db:verify-master-data:code": "node scripts/verify-master-data-code-boundaries.js", + "db:verify-master-data:all": "npm run db:verify-master-data:truth && npm run db:verify-master-data:code && npm run db:verify-master-data:migration-status && npm run db:verify-master-data", + "db:verify-master-data:all:strict": "npm run db:verify-master-data:truth && npm run db:verify-master-data:code && npm run db:verify-master-data:migration-status && npm run db:verify-master-data:strict", + "db:verify-master-data:readiness": "node scripts/verify-master-data-readiness.js", + "db:verify-master-data:readiness:audit": "node scripts/verify-master-data-readiness.js --output .master-data-audits/readiness-current.json", + "db:diagnose-prisma-migration-baseline": "node scripts/diagnose-prisma-migration-baseline.js", + "db:preflight-database-foundation-change": "node scripts/preflight-database-foundation-change.js", + "db:preflight-database-foundation-change:audit": "node scripts/preflight-database-foundation-change.js --output .master-data-audits/foundation-preflight-current.json", + "db:backup:postgres": "node scripts/create-postgres-backup.js", + "db:foundation:migrate": "node scripts/run-database-foundation-migrations.js", + "db:foundation:migrate:audit": "node scripts/run-database-foundation-migrations.js --output .master-data-audits/foundation-migrate-plan-current.json", + "db:foundation:apply-required": "node scripts/apply-master-data-foundation-migrations.js", + "db:foundation:apply-required:audit": "node scripts/apply-master-data-foundation-migrations.js --output .master-data-audits/foundation-required-migration-apply-plan-current.json", + "db:foundation:apply-structure-repair": "node scripts/apply-prisma-structure-repair.js", + "db:foundation:apply-structure-repair:audit": "node scripts/apply-prisma-structure-repair.js --output .master-data-audits/authorized-structure-repair-apply-plan-current.json", + "db:foundation:baseline-resolve": "node scripts/apply-prisma-baseline-resolve.js", + "db:foundation:rehearse-migration": "node scripts/rehearse-database-foundation-migration.js", + "db:foundation:rehearse-structure-repair": "node scripts/rehearse-prisma-structure-repair.js", + "db:foundation:baseline-repair-plan": "node scripts/plan-prisma-baseline-repair.js", + "db:foundation:plan-pre-sku-formalization-repair": "node scripts/plan-pre-sku-formalization-repair.js", + "db:foundation:validate-pre-sku-formalization-repair": "node scripts/plan-pre-sku-formalization-repair.js --validate-plan", + "db:foundation:apply-pre-sku-formalization-repair": "node scripts/apply-pre-sku-formalization-repair.js", + "db:foundation:audit": "node scripts/run-master-data-foundation-audit.js", + "db:foundation:audit:cleanup-template": "node scripts/run-master-data-foundation-audit.js --include-cleanup-template", + "db:foundation:audit:full": "node scripts/run-master-data-foundation-audit.js --include-cleanup-template --use-docker-shadow --include-structure-repair-rehearsal --output-dir .master-data-audits/foundation-audit-current-goal", + "db:foundation:completion-checklist": "node scripts/print-master-data-foundation-checklist.js", + "db:foundation:decision-package": "node scripts/print-master-data-foundation-decision-package.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.948.0", diff --git a/furniture-3d-system/prisma.config.ts b/furniture-3d-system/prisma.config.ts index f79f7597..bbf4860d 100644 --- a/furniture-3d-system/prisma.config.ts +++ b/furniture-3d-system/prisma.config.ts @@ -2,8 +2,12 @@ import fs from 'node:fs' import path from 'node:path' import { defineConfig } from 'prisma/config' -function loadDatabaseUrlFromDotEnv(): void { - if (typeof process.env.DATABASE_URL === 'string' && process.env.DATABASE_URL.length > 0) { +function loadEnvValuesFromDotEnv(names: string[]): void { + const missingNames = names.filter((name) => { + const value = process.env[name] + return !(typeof value === 'string' && value.length > 0) + }) + if (missingNames.length === 0) { return } @@ -21,7 +25,7 @@ function loadDatabaseUrlFromDotEnv(): void { if (separatorIndex <= 0) continue const key = trimmed.slice(0, separatorIndex).trim() - if (key !== 'DATABASE_URL') continue + if (!missingNames.includes(key)) continue let value = trimmed.slice(separatorIndex + 1).trim() if ( @@ -32,13 +36,12 @@ function loadDatabaseUrlFromDotEnv(): void { } if (value.length > 0) { - process.env.DATABASE_URL = value + process.env[key] = value } - return } } -loadDatabaseUrlFromDotEnv() +loadEnvValuesFromDotEnv(['DATABASE_URL', 'SHADOW_DATABASE_URL']) if (!process.env.DATABASE_URL) { throw new Error('DATABASE_URL 未配置。请在 .env 中设置 DATABASE_URL。') @@ -51,5 +54,8 @@ export default defineConfig({ }, datasource: { url: process.env.DATABASE_URL, + ...(process.env.SHADOW_DATABASE_URL + ? { shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL } + : {}), }, }) diff --git a/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql b/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql index 01c2d279..8d4566ec 100644 --- a/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql +++ b/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql @@ -2,10 +2,10 @@ CREATE TYPE "ApprovalWorkflowStatus" AS ENUM ('DRAFT', 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'REJECTED', 'ARCHIVED'); -- DropIndex -DROP INDEX "IntakeNonIntegratedProduct_spuBase_key"; +DROP INDEX IF EXISTS "IntakeNonIntegratedProduct_spuBase_key"; -- DropIndex -DROP INDEX "IntakeNonIntegratedProduct_spu_idx"; +DROP INDEX IF EXISTS "IntakeNonIntegratedProduct_spu_idx"; -- AlterTable ALTER TABLE "Department" ADD COLUMN "description" TEXT, @@ -111,10 +111,10 @@ CREATE INDEX "ApprovalWorkflowFieldChange_userId_createdAt_idx" ON "ApprovalWork CREATE INDEX "ApprovalWorkflowFieldChange_fieldKey_idx" ON "ApprovalWorkflowFieldChange"("fieldKey"); -- CreateIndex -CREATE UNIQUE INDEX "IntakeNonIntegratedProduct_spu_key" ON "IntakeNonIntegratedProduct"("spu"); +CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spu_key" ON "IntakeNonIntegratedProduct"("spu"); -- CreateIndex -CREATE INDEX "IntakeNonIntegratedProduct_spuBase_idx" ON "IntakeNonIntegratedProduct"("spuBase"); +CREATE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spuBase_idx" ON "IntakeNonIntegratedProduct"("spuBase"); -- CreateIndex CREATE INDEX "User_accountTemplateId_idx" ON "User"("accountTemplateId"); diff --git a/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql b/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql index b9045457..f7327c44 100644 --- a/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql +++ b/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql @@ -1,5 +1,5 @@ -- CreateTable -CREATE TABLE "PackagingPlan" ( +CREATE TABLE IF NOT EXISTS "PackagingPlan" ( "id" TEXT NOT NULL, "productId" TEXT NOT NULL, "name" TEXT NOT NULL, @@ -17,7 +17,21 @@ CREATE TABLE "PackagingPlan" ( ); -- CreateIndex -CREATE INDEX "PackagingPlan_productId_idx" ON "PackagingPlan"("productId"); +CREATE INDEX IF NOT EXISTS "PackagingPlan_productId_idx" ON "PackagingPlan"("productId"); -- AddForeignKey -ALTER TABLE "PackagingPlan" ADD CONSTRAINT "PackagingPlan_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'PackagingPlan_productId_fkey' + AND table_name = 'PackagingPlan' + ) THEN + ALTER TABLE "PackagingPlan" + ADD CONSTRAINT "PackagingPlan_productId_fkey" + FOREIGN KEY ("productId") REFERENCES "Product"("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + END IF; +END $$; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index e8602721..14ec908a 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -23,7 +23,7 @@ model ProductCategory { isActive Boolean @default(true) // 是否启用 // 关联产品 - products Product[] + products Product[] planningTemplates PlanningTemplate[] @relation("PlanningTemplateCategory") planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCategory") assetFamilies AssetFamily[] @relation("AssetFamilyCategory") @@ -113,6 +113,7 @@ model ProductSKU { // SKU标识 skuCode String @unique // SKU编码,如 "A001-RED-3S" + name String? // SKU名称 // 规格属性(灵活的JSON结构) // 示例: { "color": "红色", "size": "三人座", "material": "布艺" } @@ -138,22 +139,27 @@ model ProductSKU { // 状态 isActive Boolean @default(true) // 是否上架 + originalSkuCode String? + deletedCodeAt DateTime? + deletedByProductId String? + productDeletedAt DateTime? // SKU 级装配步骤(与产品级步骤隔离) - assemblySteps AssemblyStep[] + assemblySteps AssemblyStep[] // SKU 级板件 / 孔位 / 五金结构数据 - panels Panel[] - holes Hole[] - productHardwares ProductHardware[] - sourcePreSkuId String? @unique - sourcePreSku PreSku? @relation("PreSkuFormalizedSku", fields: [sourcePreSkuId], references: [id], onDelete: SetNull) - sourceAssetVariantId String? - sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull) - sourceAssetVariantIds String[] @default([]) - warehouseLocationId String? - warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) - packagedItems PackagedItem[] @relation("PackagedItemSku") - qualityInspections ProductSkuQualityInspection[] @relation("ProductSKUQualityInspections") + panels Panel[] + holes Hole[] + productHardwares ProductHardware[] + sourcePreSkuId String? @unique + sourcePreSku PreSku? @relation("PreSkuFormalizedSku", fields: [sourcePreSkuId], references: [id], onDelete: SetNull) + sourceAssetVariantId String? + sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull) + sourceAssetVariantIds String[] @default([]) + assetProfile ProductSkuAssetProfile? + warehouseLocationId String? + warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) + packagedItems PackagedItem[] @relation("PackagedItemSku") + qualityInspections ProductSkuQualityInspection[] @relation("ProductSKUQualityInspections") // 元数据 createdAt DateTime @default(now()) @@ -162,6 +168,7 @@ model ProductSKU { @@index([productId]) @@index([skuCode]) + @@index([name]) @@index([isActive]) @@index([ownerId]) @@index([sourceAssetVariantId]) @@ -169,6 +176,47 @@ model ProductSKU { @@index([warehouseLocationId]) } +// 正式 SKU 的资产资料结构化同步副本 +model ProductSkuAssetProfile { + id String @id @default(cuid()) + skuId String @unique + sku ProductSKU @relation(fields: [skuId], references: [id], onDelete: Cascade) + + primarySourceAssetVariantId String? + sourceAssetVariantIds String[] @default([]) + sourceAssetFamilyId String? + sourceAssetSeriesId String? + + model String? + modelName String? + specCode String? + specName String? + barcode String? + size Json + color String? + material String? + imageUrl String? + + sourceStatus ProductSkuAssetSourceStatus @default(ACTIVE) + skuDeletedAt DateTime? + syncedAt DateTime + lockedFields String[] @default([]) + lockedReasons Json + sourceVariants Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([primarySourceAssetVariantId]) + @@index([sourceAssetVariantIds], type: Gin) + @@index([sourceAssetFamilyId]) + @@index([sourceAssetSeriesId]) + @@index([specCode]) + @@index([barcode]) + @@index([sourceStatus]) + @@index([skuDeletedAt]) +} + // 产品协作者关系表 model ProductCollaborator { productId String @@ -177,9 +225,9 @@ model ProductCollaborator { addedAt DateTime @default(now()) addedBy String? - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - user User @relation("ProductCollaboratorUser", fields: [userId], references: [id], onDelete: Cascade) - addedByUser User? @relation("ProductCollaboratorAddedBy", fields: [addedBy], references: [id], onDelete: SetNull) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + user User @relation("ProductCollaboratorUser", fields: [userId], references: [id], onDelete: Cascade) + addedByUser User? @relation("ProductCollaboratorAddedBy", fields: [addedBy], references: [id], onDelete: SetNull) @@id([productId, userId]) @@index([productId]) @@ -188,23 +236,25 @@ model ProductCollaborator { } model IntakeNonIntegratedProduct { - id String @id - spu String - spuBase String - brand String - designer String - operator String - purchaser String - payload Json - visibility String @default("DEPARTMENT") // PRIVATE, TEAM, DEPARTMENT, PUBLIC - departmentId String? - teamId String? - createdBy String - updatedBy String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id + spu String + spuBase String + brand String + designer String + operator String + purchaser String + payload Json + visibility String @default("DEPARTMENT") // PRIVATE, TEAM, DEPARTMENT, PUBLIC + departmentId String? + teamId String? + createdBy String + updatedBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? approvalWorkflows ApprovalWorkflow[] + skuItems IntakeNonIntegratedSkuItem[] + profile IntakeNonIntegratedProductProfile? @@unique([spu, brand]) @@index([spu]) @@ -216,15 +266,231 @@ model IntakeNonIntegratedProduct { @@index([deletedAt, createdAt]) } +model IntakeNonIntegratedProductProfile { + id String @id @default(cuid()) + productId String @unique + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + productName String @default("") + productImageUrl String? @db.Text + productCategory String @default("") + salesCategory String @default("") + productYear String @default("") + productStatus String @default("") + productDevelopmentKind String @default("") + goodsType String @default("") + materialType String @default("") + productionType String @default("") + size String @default("") + color String @default("") + tagMaterial String @default("") + priceBand String @default("") + demandFlowNo String @default("") + processTemplate String @default("") + productPlanning String @default("") + brandPlanning String @default("") + demandDepartment String @default("") + spuOwner String @default("") + operationsOwner String @default("") + purchaseOwner String @default("") + visualDirector String @default("") + visualPlanning String @default("") + structureDesigner String @default("") + sourceSpu String @default("") + sourceSkuId String @default("") + sourceCategory String @default("") + sourceItemType String @default("") + sourceSalesStatus String @default("") + sourceEnabled String @default("") + sourceStockDisabled String @default("") + stockSkuCount Int @default(0) + stockHasOnSaleSku Boolean @default(false) + source String @default("PAYLOAD_SYNC") + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productName]) + @@index([productCategory]) + @@index([salesCategory]) + @@index([productStatus]) + @@index([productDevelopmentKind]) + @@index([spuOwner]) + @@index([purchaseOwner]) + @@index([sourceSpu]) + @@index([sourceSkuId]) + @@index([sourceCategory]) + @@index([sourceItemType]) + @@index([sourceSalesStatus]) + @@index([sourceEnabled]) +} + +model IntakeNonIntegratedSkuItem { + id String @id + productId String + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + skuItemId String + sortOrder Int + skuSequence Int @default(0) + batchNo Int @default(1) + packageGroupId String @default("") + image String? @db.Text + imageName String @default("") + model String @default("") + skuCode String @default("") + specName String @default("") + barcode String @default("") + size String @default("") + color String @default("") + material String @default("") + boardMaterialType String @default("") + versionNo String @default("V01") + attributeCode String @default("") + drawingId String @default("") + installMethod String @default("") + executeStandard String @default("") + nationalStandardCompliant String @default("") + manufacturer String @default("") + manufacturerCode String @default("") + salesStatus String @default("") + tagMaterial String @default("") + model3dDataUrl String? @db.Text + source String @default("") + sourceTable String @default("") + sourceSkuId String @default("") + contentLocked Boolean @default(false) + dataQualityStatus IntakeNonIntegratedSkuDataQualityStatus @default(INCOMPLETE) + missingRequiredFields String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([productId, skuItemId]) + @@index([productId]) + @@index([skuCode]) + @@index([barcode]) + @@index([model]) + @@index([dataQualityStatus]) + @@index([sourceTable, sourceSkuId]) +} + +model MasterDataQualityDecision { + id String @id @default(cuid()) + targetType String + targetId String + targetBusinessKey String @default("") + status String @default("APPROVED_INCOMPLETE") + missingFields String[] @default([]) + reason String @db.Text + decidedBy String + decidedAt DateTime @default(now()) + resolvedBy String? + resolvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([targetType, targetId]) + @@index([targetBusinessKey]) + @@index([status]) + @@index([decidedAt]) +} + +model ProductPatentRecord { + id String @id + patentGroupKey String @default("") + productId String @default("") + linkedProductIds String[] @default([]) + linkedProductNames String[] @default([]) + linkedSpus String[] @default([]) + linkedBrands String[] @default([]) + productDisplayName String @default("") + productImage String? @db.Text + productImageName String @default("") + patentStatus String @default("待确认") + patentType String @default("外观设计") + patentName String @default("") + spu String @default("") + saRequired String @default("否") + salesStatus String @default("在售") + designer String @default("") + applicationNo String @default("") + patentNo String @default("") + applicationDate String @default("") + authorizationDate String @default("") + firstFeeDate String @default("") + annualFees Json @default("{}") + annualFeeDates Json @default("{}") + nextFeeDueDate String @default("") + feeAmount String @default("") + patentCertificateFileName String @default("") + patentCertificateFileUrl String? @db.Text + patentCertificateFileMimeType String @default("") + patentCertificateStorageKey String @default("") + annualFeeNote String @default("") + continueDecision String @default("待判断") + decisionReason String @default("") + owner String @default("") + remark String @default("") + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([patentGroupKey]) + @@index([productId]) + @@index([spu]) + @@index([patentStatus]) + @@index([patentNo]) + @@index([applicationNo]) + @@index([designer]) + @@index([nextFeeDueDate]) + @@index([sortOrder]) +} + +model IntakeReferenceLibraryItem { + id String @id @default(cuid()) + libraryType String + itemKey String + code String @default("") + name String @default("") + category String @default("") + letter String @default("") + payload Json @default("{}") + sortOrder Int @default(0) + isActive Boolean @default(true) + createdBy String? + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([libraryType, itemKey]) + @@index([libraryType, isActive, sortOrder]) + @@index([libraryType, code]) + @@index([libraryType, name]) + @@index([category]) +} + +model SpuNumberCounter { + id String @id @default(cuid()) + productCategory String @unique + prefix String @unique + lastUsedIndex Int @default(0) + sourceLibraryItemId String? + createdBy String? + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([prefix]) + @@index([sourceLibraryItemId]) +} + // ============================================ // 装配相关 // ============================================ // 装配步骤(默认产品级,可选绑定到 SKU) model AssemblyStep { - id String @id @default(cuid()) + id String @id @default(cuid()) productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -287,9 +553,9 @@ model AssemblyVersion { // 板件(属于Product) model Panel { - id String @id @default(cuid()) + id String @id @default(cuid()) productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -307,7 +573,7 @@ model Panel { installOrder Int? @default(0) // 安装序号 explosionDirection String? // 爆炸方向:auto/up/down/left/right/front/back isAnchor Boolean @default(false) // 是否为基准件(不移动) - assemblySemantic Json? // 当前生效的装配语义(SKU scoped) + assemblySemantic Json? // 当前生效的装配语义(SKU scoped) // 材质关联 panelMaterial PanelMaterial? @@ -423,7 +689,7 @@ model PlanningWorkspace { category ProductCategory? @relation("PlanningWorkspaceCategory", fields: [categoryId], references: [id]) createdBy String - creator User @relation("PlanningWorkspaceCreator", fields: [createdBy], references: [id]) + creator User @relation("PlanningWorkspaceCreator", fields: [createdBy], references: [id]) departmentId String? department Department? @relation("PlanningWorkspaceDepartment", fields: [departmentId], references: [id]) @@ -445,7 +711,7 @@ model PlanningWorkspace { } model PlanningDraft { - id String @id @default(cuid()) + id String @id @default(cuid()) workspaceId String workspace PlanningWorkspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@ -459,7 +725,7 @@ model PlanningDraft { sourceSnapshot Json? createdBy String - creator User @relation("PlanningDraftCreator", fields: [createdBy], references: [id]) + creator User @relation("PlanningDraftCreator", fields: [createdBy], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -469,33 +735,34 @@ model PlanningDraft { } model Substrate { - id String @id @default(cuid()) - code String @unique - name String - description String? @db.Text - categoryId String - category SubstrateCategory @relation(fields: [categoryId], references: [id]) + id String @id @default(cuid()) + code String @unique + name String + description String? @db.Text + categoryId String + category SubstrateCategory @relation(fields: [categoryId], references: [id]) environmentGradeId String - environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id]) + environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id]) thicknessSpecId String - thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id]) + thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id]) surfaceOptionId String? - surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id]) + surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id]) systemNameOptionId String? systemNameOption SubstrateSystemNameOption? @relation(fields: [systemNameOptionId], references: [id]) - suggestedPrice Decimal? @db.Decimal(10, 2) - weightKg Float? - metadata Json? - isActive Boolean @default(true) + suggestedPrice Decimal? @db.Decimal(10, 2) + weightKg Float? + metadata Json? + isActive Boolean @default(true) createdBy String? - creator User? @relation("SubstrateCreator", fields: [createdBy], references: [id], onDelete: SetNull) + creator User? @relation("SubstrateCreator", fields: [createdBy], references: [id], onDelete: SetNull) assetVariants AssetVariant[] - finishes SubstrateFinish[] + finishes SubstrateFinish[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // 4字段规格由服务层收口:相同规格+相同花色拒绝重复,相同规格+不同花色并入已有基材 @@index([isActive]) @@index([createdBy]) @@ -504,15 +771,14 @@ model Substrate { @@index([thicknessSpecId]) @@index([surfaceOptionId]) @@index([systemNameOptionId]) - // 4字段规格由服务层收口:相同规格+相同花色拒绝重复,相同规格+不同花色并入已有基材 } model SubstrateFinish { - id String @id @default(cuid()) + id String @id @default(cuid()) substrateId String substrate Substrate @relation(fields: [substrateId], references: [id], onDelete: Cascade) finishId String - finish Material @relation(fields: [finishId], references: [id], onDelete: Cascade) + finish Material @relation(fields: [finishId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@ -522,9 +788,9 @@ model SubstrateFinish { } model SubstrateCategory { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -537,9 +803,9 @@ model SubstrateCategory { } model SubstrateEnvironmentGrade { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -552,12 +818,12 @@ model SubstrateEnvironmentGrade { } model SubstrateThicknessSpec { - id String @id @default(cuid()) - name String @unique + id String @id @default(cuid()) + name String @unique thicknessMm Float - sheetLengthMm Float @default(2440) - sheetWidthMm Float @default(1220) - sortOrder Int @default(0) + sheetLengthMm Float @default(2440) + sheetWidthMm Float @default(1220) + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -570,9 +836,9 @@ model SubstrateThicknessSpec { } model SubstrateSurfaceOption { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -585,9 +851,9 @@ model SubstrateSurfaceOption { } model SubstrateSystemNameOption { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -600,38 +866,38 @@ model SubstrateSystemNameOption { } model AssetFamily { - id String @id @default(cuid()) - type AssetType - code String @unique - name String - description String? @db.Text + id String @id @default(cuid()) + type AssetType + code String @unique + name String + description String? @db.Text productCategoryId String? productCategory ProductCategory? @relation("AssetFamilyCategory", fields: [productCategoryId], references: [id]) assetSeriesId String? - assetSeries AssetSeries? @relation("AssetFamilySeries", fields: [assetSeriesId], references: [id], onDelete: SetNull) - connectionRoles String[] @default([]) - metadata Json? - status AssetStatus @default(DRAFT) + assetSeries AssetSeries? @relation("AssetFamilySeries", fields: [assetSeriesId], references: [id], onDelete: SetNull) + connectionRoles String[] @default([]) + metadata Json? + status AssetStatus @default(DRAFT) - createdBy String? - creator User? @relation("AssetFamilyCreator", fields: [createdBy], references: [id], onDelete: SetNull) - submittedAt DateTime? - submittedById String? - submitter User? @relation("AssetFamilySubmitter", fields: [submittedById], references: [id], onDelete: SetNull) - reviewedAt DateTime? - reviewedById String? - reviewer User? @relation("AssetFamilyReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) - rejectedAt DateTime? - rejectedById String? - rejecter User? @relation("AssetFamilyRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) - rejectionReason String? @db.Text + createdBy String? + creator User? @relation("AssetFamilyCreator", fields: [createdBy], references: [id], onDelete: SetNull) + submittedAt DateTime? + submittedById String? + submitter User? @relation("AssetFamilySubmitter", fields: [submittedById], references: [id], onDelete: SetNull) + reviewedAt DateTime? + reviewedById String? + reviewer User? @relation("AssetFamilyReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) + rejectedAt DateTime? + rejectedById String? + rejecter User? @relation("AssetFamilyRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) + rejectionReason String? @db.Text lastModifiedById String? - lastModifier User? @relation("AssetFamilyLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) - deletedAt DateTime? - deletedById String? - deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) + lastModifier User? @relation("AssetFamilyLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) + deletedAt DateTime? + deletedById String? + deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) - variants AssetVariant[] + variants AssetVariant[] compatibleSeries AssetFamilySeriesCompatibility[] createdAt DateTime @default(now()) @@ -664,10 +930,10 @@ model AssetSeries { metadata Json? createdBy String? - creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull) + creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull) compatibleFamilies AssetFamilySeriesCompatibility[] - assetFamilies AssetFamily[] @relation("AssetFamilySeries") + assetFamilies AssetFamily[] @relation("AssetFamilySeries") draftGroups DraftGroup[] createdAt DateTime @default(now()) @@ -680,7 +946,7 @@ model AssetSeries { } model AssetFamilySeriesCompatibility { - id String @id @default(cuid()) + id String @id @default(cuid()) assetFamilyId String assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) assetSeriesId String @@ -694,60 +960,60 @@ model AssetFamilySeriesCompatibility { } model AssetVariant { - id String @id @default(cuid()) - assetFamilyId String - assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) - code String @unique - name String - identityKey String - assetType AssetType - status AssetStatus @default(DRAFT) - sizeLabel String? - substrateId String? - substrate Substrate? @relation(fields: [substrateId], references: [id], onDelete: SetNull) - finishId String? - finish Material? @relation("AssetVariantFinish", fields: [finishId], references: [id], onDelete: SetNull) - dimensions Json - modelUrl String - thumbnailUrl String? - barcode String? - supplierName String? - supplierCode String? - versionNo String? - mountPoints Json - assemblyBasis Json? + id String @id @default(cuid()) + assetFamilyId String + assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) + code String @unique + name String + identityKey String + assetType AssetType + status AssetStatus @default(DRAFT) + sizeLabel String? + substrateId String? + substrate Substrate? @relation(fields: [substrateId], references: [id], onDelete: SetNull) + finishId String? + finish Material? @relation("AssetVariantFinish", fields: [finishId], references: [id], onDelete: SetNull) + dimensions Json + modelUrl String + thumbnailUrl String? + barcode String? + supplierName String? + supplierCode String? + versionNo String? + mountPoints Json + assemblyBasis Json? compatibilityRules Json? - namingFields Json? - businessData Json? - weightKg Float? - costPrice Decimal? @db.Decimal(10, 2) - lossFactor Float? - estimatedCostPrice Decimal? @db.Decimal(10, 2) + namingFields Json? + 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) - derivedVariants AssetVariant[] @relation("AssetVariantInheritance") + inheritedFromVariant AssetVariant? @relation("AssetVariantInheritance", fields: [inheritedFromVariantId], references: [id], onDelete: SetNull) + derivedVariants AssetVariant[] @relation("AssetVariantInheritance") locationBindings AssetVariantLocationBinding[] - sourceProductSkus ProductSKU[] @relation("ProductSKUSourceAssetVariant") + sourceProductSkus ProductSKU[] @relation("ProductSKUSourceAssetVariant") skuSyncEvents AssetSkuSyncEvent[] - createdBy String? - creator User? @relation("AssetVariantCreator", fields: [createdBy], references: [id], onDelete: SetNull) - submittedAt DateTime? - submittedById String? - submitter User? @relation("AssetVariantSubmitter", fields: [submittedById], references: [id], onDelete: SetNull) - reviewedAt DateTime? - reviewedById String? - reviewer User? @relation("AssetVariantReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) - rejectedAt DateTime? - rejectedById String? - rejecter User? @relation("AssetVariantRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) - rejectionReason String? @db.Text + createdBy String? + creator User? @relation("AssetVariantCreator", fields: [createdBy], references: [id], onDelete: SetNull) + submittedAt DateTime? + submittedById String? + submitter User? @relation("AssetVariantSubmitter", fields: [submittedById], references: [id], onDelete: SetNull) + reviewedAt DateTime? + reviewedById String? + reviewer User? @relation("AssetVariantReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) + rejectedAt DateTime? + rejectedById String? + rejecter User? @relation("AssetVariantRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) + rejectionReason String? @db.Text lastModifiedById String? - lastModifier User? @relation("AssetVariantLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) - deletedAt DateTime? - deletedById String? - deleter User? @relation("AssetVariantDeleter", fields: [deletedById], references: [id], onDelete: SetNull) + lastModifier User? @relation("AssetVariantLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) + deletedAt DateTime? + deletedById String? + deleter User? @relation("AssetVariantDeleter", fields: [deletedById], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -806,10 +1072,10 @@ model DraftGroup { status DraftGroupStatus @default(DRAFT) summary Json - notes String? @db.Text + notes String? @db.Text createdBy String - creator User @relation("DraftGroupCreator", fields: [createdBy], references: [id]) + creator User @relation("DraftGroupCreator", fields: [createdBy], references: [id]) preSkus PreSku[] @@ -833,7 +1099,7 @@ model AssistedAssemblyAiRender { params Json referenceImageUrl String? - resultImages Json @default("[]") + resultImages Json @default("[]") workspaceSnapshot Json assetSnapshot Json @@ -841,7 +1107,7 @@ model AssistedAssemblyAiRender { externalRequestId String? attempts Json? status AssistedAssemblyAiRenderStatus @default(PENDING) - errorMessage String? @db.Text + errorMessage String? @db.Text durationMs Int? createdAt DateTime @default(now()) @@ -852,30 +1118,31 @@ model AssistedAssemblyAiRender { } model PreSku { - id String @id @default(cuid()) + id String @id @default(cuid()) draftGroupId String draftGroup DraftGroup @relation(fields: [draftGroupId], references: [id], onDelete: Cascade) - sequence Int - defaultName String - customName String? - status PreSkuStatus @default(DRAFT) - sourceCandidateKey String - familySelection Json - variantSelection Json + sequence Int + defaultName String + customName String? + status PreSkuStatus @default(DRAFT) + sourceCandidateKey String + familySelection Json + variantSelection Json recommendedHardware Json? - previewNodes Json - displaySummary Json - bomPreview Json - weightPreview Float? - componentCodes String[] - explosionPreview Json? - installStepPreview Json - packagingSnapshot Json? - notes String? @db.Text + previewNodes Json + displaySummary Json + bomPreview Json + weightPreview Float? + componentCodes String[] + explosionPreview Json? + installStepPreview Json + packagingSnapshot Json? + notes String? @db.Text - packagingPlan PreSkuPackagingPlan? - formalizedSku ProductSKU? @relation("PreSkuFormalizedSku") + basicInfo PreSkuBasicInfo? + packagingPlan PreSkuPackagingPlan? + formalizedSku ProductSKU? @relation("PreSkuFormalizedSku") locationBindings PreSkuLocationBinding[] createdAt DateTime @default(now()) @@ -886,18 +1153,50 @@ model PreSku { @@index([status]) } -model PreSkuPackagingPlan { - id String @id @default(cuid()) +model PreSkuBasicInfo { + id String @id @default(cuid()) preSkuId String @unique preSku PreSku @relation(fields: [preSkuId], references: [id], onDelete: Cascade) - sourceType PackagingSourceType @default(PRE_SKU) - sourceRefId String? - status String @default("DRAFT") - boxes Json @default("[]") - costSummary Json? - processDraft Json? - notes String? @db.Text + model String? + formalSkuCode String? + barcode String? + size String? + color String? + material String? + specName String? + versionNo String? + setCount String? + attributeCode String? + drawingId String? + installMethod String? + + manualUpdatedBy String? + manualUpdatedByName String? + manualUpdatedAt DateTime? + source String @default("DRAFT_GROUP") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([model]) + @@index([formalSkuCode]) + @@index([barcode]) + @@index([specName, versionNo]) +} + +model PreSkuPackagingPlan { + id String @id @default(cuid()) + preSkuId String @unique + preSku PreSku @relation(fields: [preSkuId], references: [id], onDelete: Cascade) + + sourceType PackagingSourceType @default(PRE_SKU) + sourceRefId String? + status String @default("DRAFT") + boxes Json @default("[]") + costSummary Json? + processDraft Json? + notes String? @db.Text packagedItems PackagedItem[] @@ -908,21 +1207,21 @@ model PreSkuPackagingPlan { } model WarehouseLocation { - id String @id @default(cuid()) - code String @unique - name String - zone String? - aisle String? - shelf String? - bin String? - notes String? @db.Text + id String @id @default(cuid()) + code String @unique + name String + zone String? + aisle String? + shelf String? + bin String? + notes String? @db.Text isActive Boolean @default(true) - skus ProductSKU[] @relation("ProductSKUWarehouseLocation") - packagedItems PackagedItem[] @relation("PackagedItemWarehouseLocation") - assetVariantBindings AssetVariantLocationBinding[] + skus ProductSKU[] @relation("ProductSKUWarehouseLocation") + packagedItems PackagedItem[] @relation("PackagedItemWarehouseLocation") + assetVariantBindings AssetVariantLocationBinding[] brandMaterialBindings BrandMaterialBinding[] - preSkuBindings PreSkuLocationBinding[] + preSkuBindings PreSkuLocationBinding[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -931,7 +1230,7 @@ model WarehouseLocation { } model AssetVariantLocationBinding { - id String @id @default(cuid()) + id String @id @default(cuid()) assetVariantId String warehouseLocationId String @@ -946,7 +1245,7 @@ model AssetVariantLocationBinding { } model BrandMaterialBinding { - id String @id @default(cuid()) + id String @id @default(cuid()) brand String materialType String warehouseLocationId String? @@ -954,8 +1253,8 @@ model BrandMaterialBinding { fileUrl String? fileName String? fileSize Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([brand, materialType]) @@index([brand]) @@ -963,7 +1262,7 @@ model BrandMaterialBinding { } model PreSkuLocationBinding { - id String @id @default(cuid()) + id String @id @default(cuid()) preSkuId String warehouseLocationId String @@ -978,23 +1277,23 @@ model PreSkuLocationBinding { } model PackagedItem { - id String @id @default(cuid()) - code String @unique - name String + id String @id @default(cuid()) + code String @unique + name String preSkuPackagingPlanId String? preSkuPackagingPlan PreSkuPackagingPlan? @relation(fields: [preSkuPackagingPlanId], references: [id], onDelete: SetNull) - skuId String? - sku ProductSKU? @relation("PackagedItemSku", fields: [skuId], references: [id], onDelete: SetNull) - warehouseLocationId String? - warehouseLocation WarehouseLocation? @relation("PackagedItemWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) - quantityPerPack Int @default(1) - stockQuantity Int @default(0) - length Float? - width Float? - height Float? - weight Float? - payload Json? - status String @default("DRAFT") + skuId String? + sku ProductSKU? @relation("PackagedItemSku", fields: [skuId], references: [id], onDelete: SetNull) + warehouseLocationId String? + warehouseLocation WarehouseLocation? @relation("PackagedItemWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) + quantityPerPack Int @default(1) + stockQuantity Int @default(0) + length Float? + width Float? + height Float? + weight Float? + payload Json? + status String @default("DRAFT") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1125,6 +1424,17 @@ enum AssetSkuSyncEventStatus { FAILED } +enum ProductSkuAssetSourceStatus { + ACTIVE + DELETED + MISSING +} + +enum IntakeNonIntegratedSkuDataQualityStatus { + READY + INCOMPLETE +} + enum PackagingSourceType { PRODUCT PRE_SKU @@ -1136,14 +1446,14 @@ model User { id String @id @default(cuid()) // 登录凭证 - email String @unique // 邮箱(用于登录) - name String @unique // 姓名/花名(用于登录和显示) - password String - loginSource String @default("LOCAL") - dingtalkUserId String? @unique - dingtalkUnionId String? @unique - dingtalkBoundAt DateTime? - employeeDirectoryId String? @unique + email String @unique // 邮箱(用于登录) + name String @unique // 姓名/花名(用于登录和显示) + password String + loginSource String @default("LOCAL") + dingtalkUserId String? @unique + dingtalkUnionId String? @unique + dingtalkBoundAt DateTime? + employeeDirectoryId String? @unique employeeDirectory EmployeeDirectory? @relation(fields: [employeeDirectoryId], references: [id], onDelete: SetNull) // 首次登录标识 @@ -1154,7 +1464,7 @@ model User { phone String? // 角色与权限 - role Role @default(USER) + role Role @default(USER) businessRoles BusinessAccountType[] @default([]) // 组织架构 @@ -1172,33 +1482,33 @@ model User { status UserStatus @default(PENDING) // 关联 - createdProducts Product[] @relation("ProductCreator") - ownedSKUs ProductSKU[] @relation("SKUOwner") - createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator") - createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator") - createdPlanningDrafts PlanningDraft[] @relation("PlanningDraftCreator") - createdSubstrates Substrate[] @relation("SubstrateCreator") - createdAssetFamilies AssetFamily[] @relation("AssetFamilyCreator") - submittedAssetFamilies AssetFamily[] @relation("AssetFamilySubmitter") - reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer") - rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter") - modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier") - deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter") - createdAssetVariants AssetVariant[] @relation("AssetVariantCreator") - submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter") - reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer") - rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter") - modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier") - deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter") - createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator") - createdDraftGroups DraftGroup[] @relation("DraftGroupCreator") - assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator") - collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") - addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") - feedbacks Feedback[] @relation("FeedbackSubmitter") - assignedFeedbacks Feedback[] @relation("FeedbackAssignee") - createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator") - approvalWorkflowChanges ApprovalWorkflowFieldChange[] + createdProducts Product[] @relation("ProductCreator") + ownedSKUs ProductSKU[] @relation("SKUOwner") + createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator") + createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator") + createdPlanningDrafts PlanningDraft[] @relation("PlanningDraftCreator") + createdSubstrates Substrate[] @relation("SubstrateCreator") + createdAssetFamilies AssetFamily[] @relation("AssetFamilyCreator") + submittedAssetFamilies AssetFamily[] @relation("AssetFamilySubmitter") + reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer") + rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter") + modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier") + deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter") + createdAssetVariants AssetVariant[] @relation("AssetVariantCreator") + submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter") + reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer") + rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter") + modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier") + deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter") + createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator") + createdDraftGroups DraftGroup[] @relation("DraftGroupCreator") + assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator") + collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") + addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") + feedbacks Feedback[] @relation("FeedbackSubmitter") + assignedFeedbacks Feedback[] @relation("FeedbackAssignee") + createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator") + approvalWorkflowChanges ApprovalWorkflowFieldChange[] // 操作日志 activityLogs ActivityLog[] @@ -1219,12 +1529,12 @@ model User { model EmployeeLookupCache { id String @id @default(cuid()) - cacheKey String @unique - keyType String - lookupValue String - result Json? + cacheKey String @unique + keyType String + lookupValue String + result Json? sourceStatus EmployeeSourceStatus? - expiresAt DateTime + expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1237,11 +1547,11 @@ model EmployeeLookupCache { model Department { id String @id @default(cuid()) - name String @unique - fullPath String // 完整路径,如 "设计部-3D组-建模小组" + name String @unique + fullPath String // 完整路径,如 "设计部-3D组-建模小组" description String? - externalId String? @unique - syncedAt DateTime? + externalId String? @unique + syncedAt DateTime? rawSnapshot Json? // 层级关系 @@ -1260,12 +1570,12 @@ model Department { teams Team[] // 产品 - products Product[] + products Product[] planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceDepartment") // 排序 - orderIndex Int @default(0) - isActive Boolean @default(true) + orderIndex Int @default(0) + isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1293,8 +1603,8 @@ model Team { members User[] @relation("TeamMembers") // 排序 - orderIndex Int @default(0) - isActive Boolean @default(true) + orderIndex Int @default(0) + isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1306,20 +1616,20 @@ model Team { model ApprovalWorkflow { id String @id @default(cuid()) - approvalNumber String @unique - title String - status ApprovalWorkflowStatus @default(DRAFT) - productId String - product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) - accessKey String @unique - businessRoles BusinessAccountType[] @default([]) - participantUserIds String[] @default([]) - initiatorId String - initiator User @relation("ApprovalWorkflowCreator", fields: [initiatorId], references: [id]) - lastSyncedAt DateTime? - fieldMatrix Json? - summary Json? - metadata Json? + approvalNumber String @unique + title String + status ApprovalWorkflowStatus @default(DRAFT) + productId String + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + accessKey String @unique + businessRoles BusinessAccountType[] @default([]) + participantUserIds String[] @default([]) + initiatorId String + initiator User @relation("ApprovalWorkflowCreator", fields: [initiatorId], references: [id]) + lastSyncedAt DateTime? + fieldMatrix Json? + summary Json? + metadata Json? fieldChanges ApprovalWorkflowFieldChange[] @@ -1334,17 +1644,17 @@ model ApprovalWorkflow { model ApprovalWorkflowFieldChange { id String @id @default(cuid()) - workflowId String - workflow ApprovalWorkflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - fieldKey String - oldValue Json? - newValue Json? - source String - sourcePage String? - fromDingtalkPage Boolean @default(false) - metadata Json? + workflowId String + workflow ApprovalWorkflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + fieldKey String + oldValue Json? + newValue Json? + source String + sourcePage String? + fromDingtalkPage Boolean @default(false) + metadata Json? createdAt DateTime @default(now()) @@ -1356,18 +1666,18 @@ model ApprovalWorkflowFieldChange { model EmployeeDirectory { id String @id @default(cuid()) - dingtalkUserId String? @unique - dingtalkUnionId String? @unique - jobNumber String? - name String - mobile String? - email String? - title String? - sourceStatus EmployeeSourceStatus @default(EMPLOYED) + dingtalkUserId String? @unique + dingtalkUnionId String? @unique + jobNumber String? + name String + mobile String? + email String? + title String? + sourceStatus EmployeeSourceStatus @default(EMPLOYED) primaryDepartmentExternalId String? - departmentExternalIds Json? - syncedAt DateTime @default(now()) - rawSnapshot Json? + departmentExternalIds Json? + syncedAt DateTime @default(now()) + rawSnapshot Json? user User? @@ -1535,7 +1845,7 @@ model Material { // 基本信息 name String // 材质名称,如 "橡木纹-自然色" - code String @unique // 花色编码 + code String @unique // 花色编码 categoryId String // 分类 ID category MaterialCategory @relation(fields: [categoryId], references: [id]) @@ -1565,8 +1875,8 @@ model Material { createdBy String? // 创建者 ID // 关联板件材质 - panelMaterials PanelMaterial[] - assetVariants AssetVariant[] @relation("AssetVariantFinish") + panelMaterials PanelMaterial[] + assetVariants AssetVariant[] @relation("AssetVariantFinish") substrateFinishes SubstrateFinish[] createdAt DateTime @default(now()) @@ -1639,10 +1949,10 @@ model ThemeConfig { model IntakeNodePermissionOverride { id String @id @default(cuid()) - targetKey String + targetKey String subjectKey String - action String - enabled Boolean @default(true) + action String + enabled Boolean @default(true) createdBy String? updatedBy String? @@ -1717,7 +2027,7 @@ model Hardware { // ===== 组合件相关 ===== // 是否为组合件主件(如把手、铰链等需要配套螺丝的五金件) isComposite Boolean @default(false) - + // 配件定义(仅当 isComposite=true 时有效) // 示例: [{ hardwareCode: "M4x25", quantity: 2, relativePositionTolerance: 50 }] // hardwareCode: 配件的五金件编码 @@ -1764,7 +2074,7 @@ model ProductHardware { // 关联产品 productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -1878,9 +2188,9 @@ model CommonComponent { modelUrl String thumbnailUrl String? - dimensions Json? + dimensions Json? specifications Json? - mountPoints Json? + mountPoints Json? isPreset Boolean @default(false) isActive Boolean @default(true) @@ -1906,7 +2216,7 @@ model Hole { // 关联板件 panelId String - panel Panel @relation(fields: [panelId], references: [id], onDelete: Cascade) + panel Panel @relation(fields: [panelId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -1968,12 +2278,12 @@ model SystemConfig { model ProviderConfig { id String @id @default(cuid()) - serviceType String // ai-render | prompt-optimizer - name String // 用户自定义名称,如 "生产环境"、"测试 Key" + serviceType String // ai-render | prompt-optimizer + name String // 用户自定义名称,如 "生产环境"、"测试 Key" baseUrl String apiKey String model String? - extra Json? // 扩展字段 + extra Json? // 扩展字段 isActive Boolean @default(false) sortOrder Int @default(0) createdAt DateTime @default(now()) diff --git a/furniture-3d-system/prisma/seed.ts b/furniture-3d-system/prisma/seed.ts index 0015554e..1109d25f 100644 --- a/furniture-3d-system/prisma/seed.ts +++ b/furniture-3d-system/prisma/seed.ts @@ -3,22 +3,53 @@ * 用于填充测试数据和演示数据 */ -import { PrismaClient, FeedbackStatus, FeedbackPriority, ActivityType } from '@prisma/client'; -import { PrismaPg } from '@prisma/adapter-pg'; -import { Pool } from 'pg'; +import type { PrismaClient } from '@prisma/client'; +import { FeedbackStatus, FeedbackPriority, ActivityType } from '@prisma/client'; import { hash } from 'bcryptjs'; import { STANDARD_FINISH_CATALOG } from '../src/lib/standard-finish-catalog'; import { buildStandardFinishMaterialData } from '../src/lib/standard-finish-sync'; import 'dotenv/config'; -// Prisma 7 需要使用适配器 -const pool = new Pool({ - connectionString: process.env.DATABASE_URL, -}); -const adapter = new PrismaPg(pool); -const prisma = new PrismaClient({ adapter }); +const SEED_CONFIRM_TOKEN = 'reset-local-seed'; +let prisma: PrismaClient | null = null; + +function assertLocalDestructiveSeedAllowed() { + if (!process.argv.includes(`--confirm=${SEED_CONFIRM_TOKEN}`)) { + throw new Error(`拒绝执行 destructive seed:必须显式追加 --confirm=${SEED_CONFIRM_TOKEN}。`); + } + if (process.env.NODE_ENV === 'production') { + throw new Error('拒绝执行 destructive seed:NODE_ENV=production。'); + } + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('拒绝执行 destructive seed:DATABASE_URL 未配置。'); + } + const parsed = new URL(databaseUrl); + if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { + throw new Error('拒绝执行 destructive seed:只允许本机数据库。'); + } +} + +function buildDemoSkuAssetProfile(input: { + color?: string; + size?: string; + material?: string; + variant?: string; +}) { + return { + size: input.size ? { label: input.size } : {}, + color: input.color ?? null, + material: input.material ?? null, + specName: input.variant ?? null, + syncedAt: new Date(), + lockedReasons: {}, + sourceVariants: [], + }; +} async function main() { + assertLocalDestructiveSeedAllowed(); + ({ prisma } = await import('../src/lib/prisma')); console.log('🌱 开始填充数据库...'); // 清理现有数据 @@ -521,7 +552,10 @@ async function main() { skuCode: `SF-001-${color.substring(0, 2)}-${size.substring(0, 2)}`, productId: sofa.id, ownerId: designers[0].id, - attributes: { color, size, material: '布艺' }, + attributes: {}, + assetProfile: { + create: buildDemoSkuAssetProfile({ color, size, material: '布艺' }), + }, price: size === '双人座' ? 2999 : 3999, costPrice: size === '双人座' ? 1500 : 2000, stock: Math.floor(Math.random() * 50) + 10, @@ -545,7 +579,10 @@ async function main() { skuCode: `${product.modelNumber}-V${j}`, productId: product.id, ownerId: designers[i % designers.length].id, - attributes: { variant: `变体${j}` }, + attributes: {}, + assetProfile: { + create: buildDemoSkuAssetProfile({ variant: `变体${j}` }), + }, price: 1000 + Math.random() * 4000, costPrice: 500 + Math.random() * 2000, stock: Math.floor(Math.random() * 100), @@ -781,6 +818,5 @@ main() process.exit(1); }) .finally(async () => { - await prisma.$disconnect(); - await pool.end(); + await prisma?.$disconnect(); }); diff --git a/furniture-3d-system/scripts/add-more-skus.ts b/furniture-3d-system/scripts/add-more-skus.ts index 5cace852..eefeaaa4 100644 --- a/furniture-3d-system/scripts/add-more-skus.ts +++ b/furniture-3d-system/scripts/add-more-skus.ts @@ -4,7 +4,27 @@ * 运行: npx tsx scripts/add-more-skus.ts */ -import { prisma } from '../src/lib/prisma' +const CONFIRM_TOKEN = 'add-local-demo-skus' +type PrismaSingleton = typeof import('../src/lib/prisma')['prisma'] + +let prisma: PrismaSingleton + +function assertLocalScriptAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`add-more-skus 只允许显式本地补数:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('add-more-skus 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('add-more-skus 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('add-more-skus 只允许连接本机数据库。') + } +} // 示例模型 URL(使用公开的 GLTF 模型) const sampleModels = [ @@ -32,6 +52,8 @@ const sizes = ['小号', '中号', '大号', 'S', 'M', 'L', 'XL'] const materials = ['实木', '板材', '金属', '布艺', '皮革', '玻璃'] async function addMoreSkus() { + assertLocalScriptAllowed() + ;({ prisma } = await import('../src/lib/prisma')) console.log('开始为产品添加更多 SKU...\n') // 获取所有用户作为潜在负责人 @@ -81,11 +103,12 @@ async function addMoreSkus() { const material = materials[Math.floor(Math.random() * materials.length)] const owner = users[Math.floor(Math.random() * users.length)] - const skuCode = `${product.modelNumber}-${color.substring(0, 2)}-${size}` + const skuCode = `${product.modelNumber}-${color.substring(0, 2)}-${size}`.trim().toUpperCase() + const normalizedSkuCode = skuCode.trim().toUpperCase() // 检查 SKU 编码是否已存在 const existing = await prisma.productSKU.findUnique({ - where: { skuCode } + where: { skuCode: normalizedSkuCode } }) if (existing) { @@ -144,7 +167,13 @@ async function addMoreSkus() { console.log(`${p.modelNumber.padEnd(12)} ${p.name.padEnd(20)} ${p._count.skus} 个 SKU`) }) - await prisma.$disconnect() } -addMoreSkus().catch(console.error) +addMoreSkus() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma?.$disconnect() + }) diff --git a/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts b/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts index cee09225..6af417f2 100644 --- a/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts +++ b/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts @@ -1,17 +1,24 @@ import 'dotenv/config' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' -import { prisma } from '../src/lib/prisma' -import { assetCenterService } from '../src/server/services/asset-center.service' -import { - ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL, - assetVariantThumbnailService, -} from '../src/server/services/asset-variant-thumbnail.service' +const CONFIRM_TOKEN = 'backfill-asset-variant-thumbnails' +const ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL = '/asset-thumbnails/placeholder.svg' +type PrismaSingleton = typeof import('../src/lib/prisma')['prisma'] +type AssetCenterService = typeof import('../src/server/services/asset-center.service')['assetCenterService'] +type AssetVariantThumbnailService = typeof import('../src/server/services/asset-variant-thumbnail.service')['assetVariantThumbnailService'] + +let prisma: PrismaSingleton +let assetCenterService: AssetCenterService +let assetVariantThumbnailService: AssetVariantThumbnailService interface BackfillOptions { renderBaseUrl: string force: boolean concurrency: number limit: number | null + apply: boolean } interface BackfillTask { @@ -62,6 +69,60 @@ function resolveOptions(): BackfillOptions { parsedLimit !== null && Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.floor(parsedLimit) : null, + apply: readFlag('--apply'), + } +} + +function databaseIdentity(databaseUrl: string): string { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + parsed.username.toLowerCase(), + parsed.hostname.toLowerCase(), + parsed.port, + parsed.pathname.replace(/^\//, '').toLowerCase(), + (parsed.searchParams.get('schema') || parsed.searchParams.get('currentSchema') || '').toLowerCase(), + ].join('|') +} + +function backupSha256(): string { + const rawPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || '' + if (!rawPath) { + throw new Error('资产缩略图回填真实写库必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。') + } + const absolutePath = path.resolve(process.cwd(), rawPath) + if (!fs.existsSync(absolutePath)) { + throw new Error('资产缩略图回填指定的数据库备份文件不存在。') + } + return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex') +} + +function assertApplyAllowed() { + if (!process.argv.includes('--apply')) { + throw new Error('资产缩略图回填默认禁止写库/生成文件;确认目标环境后请显式追加 --apply。') + } + if (readOption('--confirm') !== CONFIRM_TOKEN) { + throw new Error(`资产缩略图回填真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`) + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('资产缩略图回填真实写库必须先配置 DATABASE_URL。') + } + const expectedDatabaseIdentity = readOption('--expected-database-identity') + if (!expectedDatabaseIdentity) { + throw new Error( + `资产缩略图回填真实写库必须显式传入 --expected-database-identity=${databaseIdentity(databaseUrl)}。`, + ) + } + if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) { + throw new Error('资产缩略图回填目标库身份和当前 DATABASE_URL 不一致,禁止写库。') + } + const expectedBackupSha256 = readOption('--expected-backup-sha256') + if (!expectedBackupSha256) { + throw new Error('资产缩略图回填真实写库必须显式传入 --expected-backup-sha256。') + } + if (expectedBackupSha256 !== backupSha256()) { + throw new Error('资产缩略图回填备份 SHA-256 和当前备份文件不一致,禁止写库。') } } @@ -131,6 +192,15 @@ async function runWithConcurrency( async function main() { const options = resolveOptions() + assertApplyAllowed() + const [prismaModule, assetCenterModule, thumbnailModule] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/asset-center.service'), + import('../src/server/services/asset-variant-thumbnail.service'), + ]) + prisma = prismaModule.prisma + assetCenterService = assetCenterModule.assetCenterService + assetVariantThumbnailService = thumbnailModule.assetVariantThumbnailService console.log('开始批量补生成资产缩略图。') console.log(JSON.stringify(options, null, 2)) @@ -194,6 +264,6 @@ main() process.exitCode = 1 }) .finally(async () => { - await assetVariantThumbnailService.shutdown() - await prisma.$disconnect() + await assetVariantThumbnailService?.shutdown() + await prisma?.$disconnect() }) diff --git a/furniture-3d-system/scripts/backfill-sku-structural-data.ts b/furniture-3d-system/scripts/backfill-sku-structural-data.ts index 5e690c24..2b134913 100644 --- a/furniture-3d-system/scripts/backfill-sku-structural-data.ts +++ b/furniture-3d-system/scripts/backfill-sku-structural-data.ts @@ -1,9 +1,75 @@ import 'dotenv/config' -import { prisma } from '../src/lib/prisma' -import { readSkuCadDataFromAttributes } from '../src/lib/sku-cad-data' -import { ensureSkuStructuralData } from '../src/server/services/sku-cad-import.helper' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +const CONFIRM_TOKEN = 'backfill-sku-structural-data' + +function readOption(name: string): string { + const prefix = `${name}=` + const inline = process.argv.find((arg) => arg.startsWith(prefix)) + if (inline) return inline.slice(prefix.length) + const index = process.argv.indexOf(name) + if (index === -1) return '' + const value = process.argv[index + 1] || '' + return value.startsWith('--') ? '' : value +} + +function databaseIdentity(databaseUrl: string): string { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + parsed.pathname.replace(/^\//, ''), + parsed.searchParams.get('schema') || '', + ].join('|') +} + +function backupSha256(): string { + const backupPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || '' + if (!backupPath) return '' + const absolutePath = path.resolve(process.cwd(), backupPath) + if (!fs.existsSync(absolutePath)) return '' + return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex') +} + +function assertApplyGuards() { + if (!process.argv.includes('--apply')) return + + if (readOption('--confirm') !== CONFIRM_TOKEN) { + throw new Error(`SKU 结构数据回填真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`) + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('SKU 结构数据回填真实写库必须先配置 DATABASE_URL。') + } + const expectedDatabaseIdentity = readOption('--expected-database-identity') + if (!expectedDatabaseIdentity) { + throw new Error('SKU 结构数据回填真实写库必须显式传入 --expected-database-identity。') + } + if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) { + throw new Error('SKU 结构数据回填目标库身份和当前 DATABASE_URL 不一致,禁止写库。') + } + const expectedBackupSha256 = readOption('--expected-backup-sha256') + if (!expectedBackupSha256) { + throw new Error('SKU 结构数据回填真实写库必须显式传入 --expected-backup-sha256。') + } + if (expectedBackupSha256 !== backupSha256()) { + throw new Error('SKU 结构数据回填备份 sha256 和当前 DATABASE_BACKUP_FILE 不一致,禁止写库。') + } +} async function main() { + assertApplyGuards() + const apply = process.argv.includes('--apply') + const [{ prisma }, { readSkuCadDataFromAttributes }, { ensureSkuStructuralData }] = + await Promise.all([ + import('../src/lib/prisma'), + import('../src/lib/sku-cad-data'), + import('../src/server/services/sku-cad-import.helper'), + ]) const skus = await prisma.productSKU.findMany({ where: { deletedAt: null }, select: { @@ -16,43 +82,56 @@ async function main() { }) let scanned = 0 + let candidates = 0 let filled = 0 let skipped = 0 - for (const sku of skus) { - scanned += 1 - const cadData = readSkuCadDataFromAttributes( - (sku.attributes ?? null) as Record | null - ) + try { + for (const sku of skus) { + scanned += 1 + const cadData = readSkuCadDataFromAttributes( + (sku.attributes ?? null) as Record | null + ) - if (!cadData) { - skipped += 1 - continue + if (!cadData) { + skipped += 1 + continue + } + + const existingPanelsCount = await prisma.panel.count({ + where: { skuId: sku.id }, + }) + + if (existingPanelsCount > 0) { + skipped += 1 + continue + } + + candidates += 1 + if (!apply) { + console.log(`[backfill] dry-run candidate ${sku.skuCode}`) + continue + } + + await ensureSkuStructuralData({ + productId: sku.productId, + skuId: sku.id, + cadSourceData: cadData, + }) + + filled += 1 + console.log(`[backfill] filled ${sku.skuCode}`) } - - const existingPanelsCount = await prisma.panel.count({ - where: { skuId: sku.id }, - }) - - if (existingPanelsCount > 0) { - skipped += 1 - continue - } - - await ensureSkuStructuralData({ - productId: sku.productId, - skuId: sku.id, - cadSourceData: cadData, - }) - - filled += 1 - console.log(`[backfill] filled ${sku.skuCode}`) + } finally { + await prisma.$disconnect() } console.log( JSON.stringify( { + apply, scanned, + candidates, filled, skipped, }, @@ -67,6 +146,3 @@ main() console.error(error) process.exit(1) }) - .finally(async () => { - await prisma.$disconnect() - }) diff --git a/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts b/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts index a96778ec..7470ff2b 100644 --- a/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts +++ b/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts @@ -1,15 +1,75 @@ import 'dotenv/config' -import { prisma } from '../src/lib/prisma' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { hasSkuCadCompatibilityData } from '../src/lib/sku-cad-data' -function hasSkuCadData(attributes: unknown): boolean { - if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) { - return false +const CONFIRM_TOKEN = 'cleanup-legacy-product-structure' +type PrismaSingleton = typeof import('../src/lib/prisma')['prisma'] + +let prisma: PrismaSingleton + +function readOption(name: string): string { + const inline = process.argv.find((arg) => arg.startsWith(`${name}=`)) + if (inline) return inline.slice(name.length + 1).trim() + const index = process.argv.findIndex((arg) => arg === name) + const value = index >= 0 ? process.argv[index + 1] : '' + return value && !value.startsWith('--') ? value.trim() : '' +} + +function databaseIdentity(databaseUrl: string): string { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + parsed.username.toLowerCase(), + parsed.hostname.toLowerCase(), + parsed.port, + parsed.pathname.replace(/^\//, '').toLowerCase(), + (parsed.searchParams.get('schema') || parsed.searchParams.get('currentSchema') || '').toLowerCase(), + ].join('|') +} + +function backupSha256(): string { + const rawPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || '' + if (!rawPath) { + throw new Error('legacy cleanup apply 必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。') + } + const absolutePath = path.resolve(process.cwd(), rawPath) + if (!fs.existsSync(absolutePath)) { + throw new Error('legacy cleanup apply 指定的数据库备份文件不存在。') + } + return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex') +} + +function assertApplyAllowed() { + if (!process.argv.includes('--apply')) return + if (readOption('--confirm') !== CONFIRM_TOKEN) { + throw new Error(`legacy cleanup apply 必须显式传入 --confirm=${CONFIRM_TOKEN}。`) + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('legacy cleanup apply 必须先配置 DATABASE_URL。') + } + const expectedDatabaseIdentity = readOption('--expected-database-identity') + if (!expectedDatabaseIdentity) { + throw new Error('legacy cleanup apply 必须显式传入 --expected-database-identity。') + } + if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) { + throw new Error('legacy cleanup apply 的目标库身份和当前 DATABASE_URL 不一致。') + } + const expectedBackupSha256 = readOption('--expected-backup-sha256') + if (!expectedBackupSha256) { + throw new Error('legacy cleanup apply 必须显式传入 --expected-backup-sha256。') + } + if (expectedBackupSha256 !== backupSha256()) { + throw new Error('legacy cleanup apply 的备份 SHA-256 和当前备份文件不一致。') } - return Object.prototype.hasOwnProperty.call(attributes, '_skuCadData') } async function main() { const apply = process.argv.includes('--apply') + assertApplyAllowed() + ;({ prisma } = await import('../src/lib/prisma')) const products = await prisma.product.findMany({ where: { deletedAt: null }, @@ -46,7 +106,7 @@ async function main() { }> = [] for (const product of products) { - const activeSkuWithCadCount = product.skus.filter((sku) => hasSkuCadData(sku.attributes)).length + const activeSkuWithCadCount = product.skus.filter((sku) => hasSkuCadCompatibilityData(sku.attributes)).length if (activeSkuWithCadCount <= 0) continue const [panelCount, holeCount, hardwareCount] = await Promise.all([ diff --git a/furniture-3d-system/scripts/create-demo-data.js b/furniture-3d-system/scripts/create-demo-data.js index 37b29cc4..b291eddb 100644 --- a/furniture-3d-system/scripts/create-demo-data.js +++ b/furniture-3d-system/scripts/create-demo-data.js @@ -3,12 +3,38 @@ * 创建演示数据脚本 * 用于快速生成测试产品和装配步骤 */ +const DEMO_CONFIRM_TOKEN = 'reset-local-demo' +let prisma = null +let pool = null -const { PrismaClient } = require('@prisma/client') +function assertLocalDestructiveDemoAllowed() { + if (!process.argv.includes(`--confirm=${DEMO_CONFIRM_TOKEN}`)) { + throw new Error(`拒绝执行 destructive demo seed:必须显式追加 --confirm=${DEMO_CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('拒绝执行 destructive demo seed:NODE_ENV=production。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('拒绝执行 destructive demo seed:DATABASE_URL 未配置。') + } + const parsed = new URL(databaseUrl) + if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { + throw new Error('拒绝执行 destructive demo seed:只允许本机数据库。') + } +} -const prisma = new PrismaClient() +function createPrismaClient() { + const { PrismaClient } = require('@prisma/client') + const { PrismaPg } = require('@prisma/adapter-pg') + const { Pool } = require('pg') + pool = new Pool({ connectionString: process.env.DATABASE_URL }) + prisma = new PrismaClient({ adapter: new PrismaPg(pool) }) +} async function createDemoData() { + assertLocalDestructiveDemoAllowed() + createPrismaClient() console.log('🚀 开始创建演示数据...') try { @@ -207,7 +233,8 @@ async function createDemoData() { console.error('❌ 创建演示数据失败:', error) throw error } finally { - await prisma.$disconnect() + await prisma?.$disconnect() + await pool?.end() } } diff --git a/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh b/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh index 1b68a88d..68d6adbe 100644 --- a/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh +++ b/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh @@ -60,7 +60,7 @@ WorkingDirectory=$APP_ROOT Environment=NODE_ENV=production EnvironmentFile=$APP_ENV EnvironmentFile=$SYNC_ENV -ExecStart=/bin/bash -lc 'cd "$APP_ROOT" && ./node_modules/.bin/tsx scripts/sync-employee-directory.ts' +ExecStart=/bin/bash -lc 'cd "$APP_ROOT" && ./node_modules/.bin/tsx scripts/sync-employee-directory.ts --apply' StandardOutput=journal StandardError=journal EOF diff --git a/furniture-3d-system/scripts/performance-test.ts b/furniture-3d-system/scripts/performance-test.ts index 86e0db12..bee4b4a2 100644 --- a/furniture-3d-system/scripts/performance-test.ts +++ b/furniture-3d-system/scripts/performance-test.ts @@ -3,9 +3,27 @@ * 用于验证索引优化效果 */ -import { PrismaClient } from '@prisma/client'; +const CONFIRM_TOKEN = 'run-local-performance-test' +type PrismaSingleton = typeof import('../src/lib/prisma')['prisma'] -const prisma = new PrismaClient(); +let prisma: PrismaSingleton + +function assertLocalReadTestAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`performance-test 只允许显式本地读库测试:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('performance-test 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('performance-test 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('performance-test 只允许连接本机数据库,避免对生产库制造读压力。') + } +} interface TestResult { name: string; @@ -26,6 +44,8 @@ async function measureQuery(name: string, queryFn: () => Promise): Prom } async function main() { + assertLocalReadTestAllowed() + ;({ prisma } = await import('../src/lib/prisma')) console.log('🚀 开始性能测试...\n'); const results: TestResult[] = []; @@ -365,8 +385,8 @@ async function main() { main() .catch((e) => { console.error('❌ 性能测试失败:', e); - process.exit(1); + process.exitCode = 1; }) .finally(async () => { - await prisma.$disconnect(); + await prisma?.$disconnect(); }); diff --git a/furniture-3d-system/scripts/seed-open-platform-demo.ts b/furniture-3d-system/scripts/seed-open-platform-demo.ts index 94a6f775..3a3c985f 100644 --- a/furniture-3d-system/scripts/seed-open-platform-demo.ts +++ b/furniture-3d-system/scripts/seed-open-platform-demo.ts @@ -1,13 +1,38 @@ import 'dotenv/config' -import { prisma } from '../src/lib/prisma' -import { openPlatformDemoSeedService } from '../src/server/services/open-platform-demo-seed.service' +const CONFIRM_TOKEN = 'seed-local-open-platform-demo' + +function assertLocalDemoSeedAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`open-platform demo seed 只允许显式本地造数:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('open-platform demo seed 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('open-platform demo seed 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('open-platform demo seed 只允许连接本机数据库。') + } +} async function main() { - const result = await openPlatformDemoSeedService.seed() + assertLocalDemoSeedAllowed() + const [{ prisma }, { openPlatformDemoSeedService }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/open-platform-demo-seed.service'), + ]) + try { + const result = await openPlatformDemoSeedService.seed() - console.log('开放平台演示数据已完成。') - console.log(JSON.stringify(result, null, 2)) + console.log('开放平台演示数据已完成。') + console.log(JSON.stringify(result, null, 2)) + } finally { + await prisma.$disconnect() + } } main() @@ -16,6 +41,3 @@ main() console.error(error) process.exitCode = 1 }) - .finally(async () => { - await prisma.$disconnect() - }) diff --git a/furniture-3d-system/scripts/seed-test-products.ts b/furniture-3d-system/scripts/seed-test-products.ts index bc971e0d..fa21c8ab 100644 --- a/furniture-3d-system/scripts/seed-test-products.ts +++ b/furniture-3d-system/scripts/seed-test-products.ts @@ -1,25 +1,53 @@ /** * 创建测试产品数据 */ +const CONFIRM_TOKEN = 'seed-local-test-products' -import { PrismaClient } from '@prisma/client' +function assertLocalScriptAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`seed-test-products 只允许显式本地补数:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('seed-test-products 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('seed-test-products 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('seed-test-products 只允许连接本机数据库。') + } +} -const prisma = new PrismaClient() +function buildDemoAssetProfile(profile: { color: string; size: string; material: string }) { + return { + size: { label: profile.size }, + color: profile.color, + material: profile.material, + syncedAt: new Date(), + lockedReasons: {}, + sourceVariants: [], + } +} async function main() { + assertLocalScriptAllowed() + const { prisma } = await import('../src/lib/prisma') console.log('开始创建测试产品...') - // 获取第一个用户作为创建者和 SKU 负责人 - const user = await prisma.user.findFirst({ - where: { status: 'ACTIVE' }, - }) + try { + // 获取第一个用户作为创建者和 SKU 负责人 + const user = await prisma.user.findFirst({ + where: { status: 'ACTIVE' }, + }) - if (!user) { - console.error('❌ 没有找到活跃用户,请先创建用户') - process.exit(1) - } + if (!user) { + console.error('❌ 没有找到活跃用户,请先创建用户') + process.exit(1) + } - console.log(`使用用户: ${user.name} (${user.email})`) + console.log(`使用用户: ${user.name} (${user.email})`) // 测试产品数据 const testProducts = [ @@ -32,7 +60,7 @@ async function main() { skus: [ { skuCode: 'SF-001-GRAY-3S', - attributes: { color: '灰色', size: '三人座', material: '布艺' }, + assetProfile: { color: '灰色', size: '三人座', material: '布艺' }, price: 3999.00, costPrice: 2000.00, stock: 50, @@ -40,7 +68,7 @@ async function main() { }, { skuCode: 'SF-001-BLUE-3S', - attributes: { color: '蓝色', size: '三人座', material: '布艺' }, + assetProfile: { color: '蓝色', size: '三人座', material: '布艺' }, price: 3999.00, costPrice: 2000.00, stock: 30, @@ -86,7 +114,7 @@ async function main() { skus: [ { skuCode: 'TB-002-OAK-L', - attributes: { color: '橡木色', size: '大号(1.6m)', material: '实木' }, + assetProfile: { color: '橡木色', size: '大号(1.6m)', material: '实木' }, price: 2599.00, costPrice: 1300.00, stock: 40, @@ -132,7 +160,7 @@ async function main() { skus: [ { skuCode: 'BK-003-WHITE-5L', - attributes: { color: '白色', size: '五层', material: '环保板材' }, + assetProfile: { color: '白色', size: '五层', material: '环保板材' }, price: 1299.00, costPrice: 650.00, stock: 60, @@ -140,7 +168,7 @@ async function main() { }, { skuCode: 'BK-003-WALNUT-5L', - attributes: { color: '胡桃木色', size: '五层', material: '环保板材' }, + assetProfile: { color: '胡桃木色', size: '五层', material: '环保板材' }, price: 1399.00, costPrice: 700.00, stock: 45, @@ -189,7 +217,7 @@ async function main() { ] // 创建产品 - for (const productData of testProducts) { + for (const productData of testProducts) { console.log(`\n创建产品: ${productData.modelNumber} - ${productData.name}`) const { skus, assemblySteps, ...productInfo } = productData @@ -199,11 +227,15 @@ async function main() { ...productInfo, createdBy: user.id, skus: { - create: skus.map(sku => ({ + create: skus.map(({ assetProfile, ...sku }) => ({ ...sku, + attributes: {}, price: sku.price, costPrice: sku.costPrice, ownerId: user.id, + assetProfile: { + create: buildDemoAssetProfile(assetProfile), + }, })), }, assemblySteps: { @@ -227,9 +259,12 @@ async function main() { console.log(` - SKU数量: ${product.skus.length}`) console.log(` - 装配步骤: ${product.assemblySteps.length}`) console.log(` - 分享链接: ${product.shareLinks.length}`) - } + } - console.log('\n✅ 所有测试产品创建完成!') + console.log('\n✅ 所有测试产品创建完成!') + } finally { + await prisma.$disconnect() + } } main() @@ -237,6 +272,3 @@ main() console.error('❌ 创建失败:', e) process.exit(1) }) - .finally(async () => { - await prisma.$disconnect() - }) diff --git a/furniture-3d-system/scripts/sync-board-material-library.ts b/furniture-3d-system/scripts/sync-board-material-library.ts index 8c1e4c37..23ff0734 100644 --- a/furniture-3d-system/scripts/sync-board-material-library.ts +++ b/furniture-3d-system/scripts/sync-board-material-library.ts @@ -1,7 +1,9 @@ import "dotenv/config"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import { Prisma } from "@prisma/client"; -import { prisma } from "../src/lib/prisma"; import { BOARD_ALL_PANEL_FINISHES, BOARD_LIBRARY_DEFAULTS, @@ -10,6 +12,75 @@ import { } from "../src/lib/board-substrate-rules"; const BOARD_IMPORT_SOURCE = "BOARD_LIBRARY_DEFAULTS_2026_04_20"; +const CONFIRM_TOKEN = "sync-board-material-library"; +type PrismaSingleton = typeof import("../src/lib/prisma")["prisma"]; + +let prisma: PrismaSingleton; + +function assertApplyAllowed() { + if (!process.argv.includes("--apply")) { + throw new Error("板材基础资料库同步默认禁止写库;确认目标环境后请显式追加 --apply。"); + } + if (readOption("--confirm") !== CONFIRM_TOKEN) { + throw new Error(`板材基础资料库同步真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`); + } + const databaseUrl = process.env.DATABASE_URL || ""; + if (!databaseUrl) { + throw new Error("板材基础资料库同步真实写库必须先配置 DATABASE_URL。"); + } + const expectedDatabaseIdentity = readOption("--expected-database-identity"); + if (!expectedDatabaseIdentity) { + throw new Error( + `板材基础资料库同步真实写库必须显式传入 --expected-database-identity=${databaseIdentity(databaseUrl)}。`, + ); + } + if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) { + throw new Error("板材基础资料库同步目标库身份和当前 DATABASE_URL 不一致,禁止写库。"); + } + const expectedBackupSha256 = readOption("--expected-backup-sha256"); + if (!expectedBackupSha256) { + throw new Error("板材基础资料库同步真实写库必须显式传入 --expected-backup-sha256。"); + } + if (expectedBackupSha256 !== backupSha256()) { + throw new Error("板材基础资料库同步备份 SHA-256 和当前备份文件不一致,禁止写库。"); + } +} + +function readOption(name: string): string { + const inline = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1).trim(); + const index = process.argv.findIndex((arg) => arg === name); + const value = index >= 0 ? process.argv[index + 1] : ""; + return value && !value.startsWith("--") ? value.trim() : ""; +} + +function databaseIdentity(databaseUrl: string): string { + const parsed = new URL(databaseUrl); + return [ + parsed.protocol.replace(/:$/, ""), + parsed.username.toLowerCase(), + parsed.hostname.toLowerCase(), + parsed.port, + parsed.pathname.replace(/^\//, "").toLowerCase(), + (parsed.searchParams.get("schema") || parsed.searchParams.get("currentSchema") || "").toLowerCase(), + ].join("|"); +} + +function backupSha256(): string { + const rawPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || ""; + if (!rawPath) { + throw new Error("板材基础资料库同步真实写库必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。"); + } + const absolutePath = path.resolve(process.cwd(), rawPath); + if (!fs.existsSync(absolutePath)) { + throw new Error("板材基础资料库同步指定的数据库备份文件不存在。"); + } + return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex"); +} + +function normalizeCoreIdentity(value: string): string { + return value.trim().toUpperCase(); +} const SUBSTRATE_CATEGORY_SORT_ORDER: Record = { 密度板: 10, @@ -329,6 +400,9 @@ async function createCodeGenerator(options: { } async function main() { + assertApplyAllowed(); + ({ prisma } = await import("../src/lib/prisma")); + const summary: SyncSummary = { materialCategoriesCreated: 0, substrateCategoriesCreated: 0, @@ -363,7 +437,7 @@ async function main() { exists: async (code) => Boolean( await prisma.material.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, select: { id: true }, }), ), @@ -382,7 +456,7 @@ async function main() { exists: async (code) => Boolean( await prisma.substrate.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, select: { id: true }, }), ), @@ -572,5 +646,5 @@ main() process.exitCode = 1; }) .finally(async () => { - await prisma.$disconnect(); + await prisma?.$disconnect(); }); diff --git a/furniture-3d-system/scripts/sync-employee-directory.ts b/furniture-3d-system/scripts/sync-employee-directory.ts index bcb985c8..d0131b8b 100644 --- a/furniture-3d-system/scripts/sync-employee-directory.ts +++ b/furniture-3d-system/scripts/sync-employee-directory.ts @@ -6,6 +6,7 @@ import { type CliOptions = { dryRun: boolean + apply: boolean } type QueryResult = { @@ -13,8 +14,14 @@ type QueryResult = { } function parseCliOptions(argv: string[]): CliOptions { + const apply = argv.includes('--apply') + const dryRun = argv.includes('--dry-run') || !apply + if (apply && argv.includes('--dry-run')) { + throw new Error('不能同时传入 --apply 和 --dry-run。') + } return { - dryRun: argv.includes('--dry-run'), + dryRun, + apply, } } diff --git a/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts b/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts index dcb4e085..3efc08eb 100644 --- a/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts +++ b/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts @@ -1,14 +1,17 @@ import 'dotenv/config' -import { prisma } from '../src/lib/prisma' -import { syncJstSkuStockProducts } from '../src/server/services/jst-sku-stock-sync.service' type CliOptions = { apply: boolean batchSize?: number + confirm: string + expectedDatabaseIdentity: string + expectedSourceDatabaseIdentity: string limit?: number help: boolean } +export const CONFIRM_TOKEN = 'sync-jst-sku-stock' + function readNumberArg(value: string | undefined, name: string) { if (!value) return undefined @@ -23,6 +26,9 @@ function readNumberArg(value: string | undefined, name: string) { function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { apply: false, + confirm: '', + expectedDatabaseIdentity: '', + expectedSourceDatabaseIdentity: '', help: false, } @@ -42,6 +48,21 @@ function parseArgs(argv: string[]): CliOptions { return } + if (arg.startsWith('--confirm=')) { + options.confirm = arg.slice('--confirm='.length) + return + } + + if (arg.startsWith('--expected-database-identity=')) { + options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length) + return + } + + if (arg.startsWith('--expected-source-database-identity=')) { + options.expectedSourceDatabaseIdentity = arg.slice('--expected-source-database-identity='.length) + return + } + if (arg.startsWith('--batch-size=')) { options.batchSize = readNumberArg(arg.split('=')[1], '--batch-size') return @@ -58,12 +79,78 @@ function parseArgs(argv: string[]): CliOptions { return options } +function databaseIdentityFromUrl(databaseUrl: string) { + const parsed = new URL(databaseUrl) + const protocol = parsed.protocol.replace(/:$/, '') + const database = parsed.pathname.replace(/^\//, '') + const schema = parsed.searchParams.get('schema') ?? '' + return [ + protocol, + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + database, + schema, + ].join('|') +} + +function sourceDatabaseIdentityFromEnv(env: NodeJS.ProcessEnv = process.env) { + const sourceDatabaseUrl = env.JST_SKU_DATABASE_URL?.trim() + if (sourceDatabaseUrl) { + return databaseIdentityFromUrl(sourceDatabaseUrl) + } + + const host = env.JST_SKU_DB_HOST?.trim() ?? '' + const database = env.JST_SKU_DB_NAME?.trim() ?? '' + const user = env.JST_SKU_DB_USER?.trim() ?? '' + if (!host || !database || !user) return '' + + return ['mysql', user, host, env.JST_SKU_DB_PORT?.trim() || '3306', database, ''].join('|') +} + +export function assertApplyGuards(options: CliOptions, env: NodeJS.ProcessEnv = process.env) { + if (!options.apply) return + + if (options.confirm !== CONFIRM_TOKEN) { + throw new Error(`聚水潭存量商品同步真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`) + } + + const databaseUrl = env.DATABASE_URL?.trim() ?? '' + if (!databaseUrl) { + throw new Error('聚水潭存量商品同步真实写库必须先配置 DATABASE_URL。') + } + + const actualDatabaseIdentity = databaseIdentityFromUrl(databaseUrl) + if (!options.expectedDatabaseIdentity) { + throw new Error( + `聚水潭存量商品同步真实写库必须显式传入 --expected-database-identity=${actualDatabaseIdentity}。`, + ) + } + if (options.expectedDatabaseIdentity !== actualDatabaseIdentity) { + throw new Error('聚水潭存量商品同步目标库身份和当前 DATABASE_URL 不一致,禁止写库。') + } + + const actualSourceDatabaseIdentity = sourceDatabaseIdentityFromEnv(env) + if (!actualSourceDatabaseIdentity) { + throw new Error('聚水潭存量商品同步真实写库必须先配置 JST_SKU_DATABASE_URL 或 JST_SKU_DB_* 来源库信息。') + } + if (!options.expectedSourceDatabaseIdentity) { + throw new Error( + `聚水潭存量商品同步真实写库必须显式传入 --expected-source-database-identity=${actualSourceDatabaseIdentity}。`, + ) + } + if (options.expectedSourceDatabaseIdentity !== actualSourceDatabaseIdentity) { + throw new Error('聚水潭存量商品同步来源库身份和当前 JST_SKU 来源配置不一致,禁止写库。') + } +} + function printHelp() { console.log(` Usage: - npm run sync:jst-sku-stock -- [--dry-run] [--apply] [--limit=100] [--batch-size=500] + npm run sync:jst-sku-stock -- [--dry-run] [--apply] [--confirm=${CONFIRM_TOKEN}] [--expected-database-identity=postgresql|user|host|port|database|schema] [--expected-source-database-identity=mysql|user|host|port|database|] [--limit=100] [--batch-size=500] Default mode is --dry-run. Use --apply only when you want to write changes. +真实写库必须同时确认目标 PostgreSQL 身份和来源 MySQL 身份,避免错库同步。 Required environment variables: DATABASE_URL @@ -89,19 +176,26 @@ async function main() { printHelp() return } + assertApplyGuards(options) - const result = await syncJstSkuStockProducts({ - apply: options.apply, - batchSize: options.batchSize, - limit: options.limit, - }) + const [{ prisma }, { syncJstSkuStockProducts }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/jst-sku-stock-sync.service'), + ]) + try { + const result = await syncJstSkuStockProducts({ + apply: options.apply, + batchSize: options.batchSize, + limit: options.limit, + }) - console.log(JSON.stringify(result, null, 2)) + console.log(JSON.stringify(result, null, 2)) + } finally { + await prisma.$disconnect() + } } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 -}).finally(async () => { - await prisma.$disconnect() }) diff --git a/furniture-3d-system/scripts/sync-standard-finishes.ts b/furniture-3d-system/scripts/sync-standard-finishes.ts index aea2a13d..c063fa92 100644 --- a/furniture-3d-system/scripts/sync-standard-finishes.ts +++ b/furniture-3d-system/scripts/sync-standard-finishes.ts @@ -1,9 +1,16 @@ import 'dotenv/config' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' -import { prisma } from '../src/lib/prisma' import { STANDARD_FINISH_CATALOG } from '../src/lib/standard-finish-catalog' import { buildStandardFinishMaterialData } from '../src/lib/standard-finish-sync' +const CONFIRM_TOKEN = 'sync-standard-finishes' +type PrismaSingleton = typeof import('../src/lib/prisma')['prisma'] + +let prisma: PrismaSingleton + interface SyncSummary { categoriesCreated: number categoriesUpdated: number @@ -13,7 +20,71 @@ interface SyncSummary { materialsDeactivated: number } +function assertApplyAllowed() { + if (!process.argv.includes('--apply')) { + throw new Error('标准饰面同步默认禁止写库;确认目标环境后请显式追加 --apply。') + } + if (readOption('--confirm') !== CONFIRM_TOKEN) { + throw new Error(`标准饰面同步真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`) + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('标准饰面同步真实写库必须先配置 DATABASE_URL。') + } + const expectedDatabaseIdentity = readOption('--expected-database-identity') + if (!expectedDatabaseIdentity) { + throw new Error( + `标准饰面同步真实写库必须显式传入 --expected-database-identity=${databaseIdentity(databaseUrl)}。`, + ) + } + if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) { + throw new Error('标准饰面同步目标库身份和当前 DATABASE_URL 不一致,禁止写库。') + } + const expectedBackupSha256 = readOption('--expected-backup-sha256') + if (!expectedBackupSha256) { + throw new Error('标准饰面同步真实写库必须显式传入 --expected-backup-sha256。') + } + if (expectedBackupSha256 !== backupSha256()) { + throw new Error('标准饰面同步备份 SHA-256 和当前备份文件不一致,禁止写库。') + } +} + +function readOption(name: string): string { + const inline = process.argv.find((arg) => arg.startsWith(`${name}=`)) + if (inline) return inline.slice(name.length + 1).trim() + const index = process.argv.findIndex((arg) => arg === name) + const value = index >= 0 ? process.argv[index + 1] : '' + return value && !value.startsWith('--') ? value.trim() : '' +} + +function databaseIdentity(databaseUrl: string): string { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + parsed.username.toLowerCase(), + parsed.hostname.toLowerCase(), + parsed.port, + parsed.pathname.replace(/^\//, '').toLowerCase(), + (parsed.searchParams.get('schema') || parsed.searchParams.get('currentSchema') || '').toLowerCase(), + ].join('|') +} + +function backupSha256(): string { + const rawPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || '' + if (!rawPath) { + throw new Error('标准饰面同步真实写库必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。') + } + const absolutePath = path.resolve(process.cwd(), rawPath) + if (!fs.existsSync(absolutePath)) { + throw new Error('标准饰面同步指定的数据库备份文件不存在。') + } + return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex') +} + async function main() { + assertApplyAllowed() + ;({ prisma } = await import('../src/lib/prisma')) + const summary: SyncSummary = { categoriesCreated: 0, categoriesUpdated: 0, @@ -165,5 +236,5 @@ main() process.exitCode = 1 }) .finally(async () => { - await prisma.$disconnect() + await prisma?.$disconnect() }) diff --git a/furniture-3d-system/scripts/update-demo-models.js b/furniture-3d-system/scripts/update-demo-models.js index 0080c460..749cbbc0 100644 --- a/furniture-3d-system/scripts/update-demo-models.js +++ b/furniture-3d-system/scripts/update-demo-models.js @@ -3,10 +3,34 @@ * 更新演示数据脚本 - 添加真实的3D模型 * 使用更好的3D模型替换默认的演示模型 */ +const DEMO_CONFIRM_TOKEN = 'reset-local-demo' +let prisma = null +let pool = null -const { PrismaClient } = require('@prisma/client') +function assertLocalDestructiveDemoAllowed() { + if (!process.argv.includes(`--confirm=${DEMO_CONFIRM_TOKEN}`)) { + throw new Error(`拒绝执行 destructive demo seed:必须显式追加 --confirm=${DEMO_CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('拒绝执行 destructive demo seed:NODE_ENV=production。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('拒绝执行 destructive demo seed:DATABASE_URL 未配置。') + } + const parsed = new URL(databaseUrl) + if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { + throw new Error('拒绝执行 destructive demo seed:只允许本机数据库。') + } +} -const prisma = new PrismaClient() +function createPrismaClient() { + const { PrismaClient } = require('@prisma/client') + const { PrismaPg } = require('@prisma/adapter-pg') + const { Pool } = require('pg') + pool = new Pool({ connectionString: process.env.DATABASE_URL }) + prisma = new PrismaClient({ adapter: new PrismaPg(pool) }) +} // 优质的免费3D模型资源 const QUALITY_MODELS = [ @@ -37,6 +61,8 @@ const QUALITY_MODELS = [ ] async function updateDemoModels() { + assertLocalDestructiveDemoAllowed() + createPrismaClient() console.log('🔄 开始更新演示模型...') try { @@ -126,7 +152,8 @@ async function updateDemoModels() { console.error('❌ 更新演示模型失败:', error) throw error } finally { - await prisma.$disconnect() + await prisma?.$disconnect() + await pool?.end() } } diff --git a/furniture-3d-system/scripts/update-model-urls.ts b/furniture-3d-system/scripts/update-model-urls.ts index f7f39adc..270419fb 100644 --- a/furniture-3d-system/scripts/update-model-urls.ts +++ b/furniture-3d-system/scripts/update-model-urls.ts @@ -1,6 +1,21 @@ -import { PrismaClient } from '@prisma/client' +const CONFIRM_TOKEN = 'update-local-model-urls' -const prisma = new PrismaClient() +function assertLocalModelUrlUpdateAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`update-model-urls 只允许显式本地维护:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('update-model-urls 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('update-model-urls 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('update-model-urls 只允许连接本机数据库。') + } +} // Three.js 官方示例模型 URL 列表 const THREE_JS_MODELS = { @@ -46,14 +61,17 @@ const THUMBNAILS = { } async function main() { + assertLocalModelUrlUpdateAllowed() + const { prisma } = await import('../src/lib/prisma') console.log('开始更新产品模型 URL...\n') - // 获取所有产品 - const products = await prisma.product.findMany({ - orderBy: { modelNumber: 'asc' } - }) + try { + // 获取所有产品 + const products = await prisma.product.findMany({ + orderBy: { modelNumber: 'asc' } + }) - console.log(`找到 ${products.length} 个产品\n`) + console.log(`找到 ${products.length} 个产品\n`) // 模型分配方案(模拟不同产品使用不同模型) const modelAssignments: Record = { @@ -79,50 +97,52 @@ async function main() { { model: 'Ferrari' as const, thumbnail: 'Ferrari' as const }, ] - let updatedCount = 0 + let updatedCount = 0 - for (let i = 0; i < products.length; i++) { - const product = products[i] + for (let i = 0; i < products.length; i++) { + const product = products[i] - // 查找分配的模型 - let assignment = modelAssignments[product.modelNumber] + // 查找分配的模型 + let assignment = modelAssignments[product.modelNumber] - // 如果没有特定分配,使用默认模型轮换 - if (!assignment) { - assignment = defaultModels[i % defaultModels.length] - } - - const modelUrl = THREE_JS_MODELS[assignment.model] - const thumbnailUrl = assignment.thumbnail ? THUMBNAILS[assignment.thumbnail] : null - - // 更新产品 - await prisma.product.update({ - where: { id: product.id }, - data: { - modelUrl, - ...(thumbnailUrl && { thumbnailUrl }) + // 如果没有特定分配,使用默认模型轮换 + if (!assignment) { + assignment = defaultModels[i % defaultModels.length] } - }) - console.log(`✓ ${product.modelNumber} (${product.name})`) - console.log(` 模型: ${assignment.model}`) - if (thumbnailUrl) { - console.log(` 预览图: ${assignment.thumbnail}`) + const modelUrl = THREE_JS_MODELS[assignment.model] + const thumbnailUrl = assignment.thumbnail ? THUMBNAILS[assignment.thumbnail] : null + + // 更新产品 + await prisma.product.update({ + where: { id: product.id }, + data: { + modelUrl, + ...(thumbnailUrl && { thumbnailUrl }) + } + }) + + console.log(`✓ ${product.modelNumber} (${product.name})`) + console.log(` 模型: ${assignment.model}`) + if (thumbnailUrl) { + console.log(` 预览图: ${assignment.thumbnail}`) + } + console.log() + + updatedCount++ } - console.log() - updatedCount++ - } + console.log(`\n完成!已更新 ${updatedCount} 个产品的模型 URL`) - console.log(`\n完成!已更新 ${updatedCount} 个产品的模型 URL`) - - // 显示模型 URL 汇总 - console.log('\n=== 可用的 Three.js 示例模型 ===') - for (const [name, url] of Object.entries(THREE_JS_MODELS)) { - console.log(`${name}: ${url}`) + // 显示模型 URL 汇总 + console.log('\n=== 可用的 Three.js 示例模型 ===') + for (const [name, url] of Object.entries(THREE_JS_MODELS)) { + console.log(`${name}: ${url}`) + } + } finally { + await prisma.$disconnect() } } main() .catch(console.error) - .finally(() => prisma.$disconnect()) diff --git a/furniture-3d-system/scripts/update-test-data.ts b/furniture-3d-system/scripts/update-test-data.ts index 3259b35e..21366e9b 100644 --- a/furniture-3d-system/scripts/update-test-data.ts +++ b/furniture-3d-system/scripts/update-test-data.ts @@ -2,27 +2,55 @@ * 更新测试数据脚本 * 修复数据库中的测试产品数据,使其可以正常显示 */ +const CONFIRM_TOKEN = 'update-local-test-data' -import { PrismaClient } from '@prisma/client' +function assertLocalScriptAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`update-test-data 只允许显式本地补数:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('update-test-data 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('update-test-data 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('update-test-data 只允许连接本机数据库。') + } +} -const prisma = new PrismaClient() +function buildDefaultAssetProfile() { + return { + size: { label: '标准尺寸' }, + color: '原木色', + material: '实木', + syncedAt: new Date(), + lockedReasons: {}, + sourceVariants: [], + } +} async function main() { + assertLocalScriptAllowed() + const { prisma } = await import('../src/lib/prisma') console.log('开始更新测试数据...') - // 获取所有产品 - const products = await prisma.product.findMany({ - include: { - skus: true, - assemblySteps: true, - shareLinks: true, - } - }) + try { + // 获取所有产品 + const products = await prisma.product.findMany({ + include: { + skus: true, + assemblySteps: true, + shareLinks: true, + } + }) - console.log(`找到 ${products.length} 个产品`) + console.log(`找到 ${products.length} 个产品`) - // 更新每个产品 - for (const product of products) { + // 更新每个产品 + for (const product of products) { console.log(`\n更新产品: ${product.modelNumber} - ${product.name}`) // 确保产品有基本信息 @@ -52,17 +80,16 @@ async function main() { data: { productId: product.id, skuCode: `${product.modelNumber}-DEFAULT`, - attributes: { - color: '原木色', - size: '标准尺寸', - material: '实木' - }, + attributes: {}, price: 2999.00, costPrice: 1500.00, stock: 100, images: ['/placeholder-furniture.jpg'], isActive: true, ownerId: skuOwner.id, + assetProfile: { + create: buildDefaultAssetProfile(), + }, } }) } else { @@ -133,9 +160,12 @@ async function main() { }) } } - } + } - console.log('\n✅ 测试数据更新完成!') + console.log('\n✅ 测试数据更新完成!') + } finally { + await prisma.$disconnect() + } } main() @@ -143,6 +173,3 @@ main() console.error('❌ 更新失败:', e) process.exit(1) }) - .finally(async () => { - await prisma.$disconnect() - }) diff --git a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx index 8bef05c2..579c3752 100644 --- a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx @@ -205,6 +205,12 @@ jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ }, })) +jest.mock('@/server/services/category.service', () => ({ + categoryService: { + listActive: jest.fn().mockResolvedValue([]), + }, +})) + function createDraftGroupDetail() { return { draftGroup: createMockDraftGroupList()[0], diff --git a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx index 2fcd72a3..adff9dfa 100644 --- a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx @@ -36,6 +36,114 @@ jest.mock('@/lib/server-session', () => ({ }), })) +jest.mock('@/server/services/draft-group.service', () => ({ + draftGroupService: { + list: jest.fn().mockResolvedValue([ + { + id: 'draft-1', + code: 'DG-001', + name: '包装中草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + { + id: 'draft-2', + code: 'DG-002', + name: '待入库草稿', + status: 'READY_FOR_INTAKE', + assetSeriesName: '单板桌', + }, + { + id: 'draft-3', + code: 'DG-002', + name: '未包装草稿', + status: 'DRAFT', + assetSeriesName: '单板桌', + }, + { + id: 'draft-4', + code: 'DG-003', + name: '已入库草稿', + status: 'FORMALIZED', + assetSeriesName: '单板桌', + }, + { + id: 'draft-5', + code: 'DG-004', + name: '已归档草稿', + status: 'ARCHIVED', + assetSeriesName: '单板桌', + }, + ]), + getById: jest.fn().mockResolvedValue({ + draftGroup: { + id: 'draft-1', + code: 'DG-001', + name: '包装中草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + preSkus: [], + }), + }, +})) + +jest.mock('@/server/services/intake.service', () => ({ + intakeService: { + recommendExistingProducts: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/warehouse.service', () => ({ + warehouseService: { + listLocations: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/asset-center.service', () => ({ + assetCenterService: { + listSubstrates: jest.fn().mockResolvedValue([]), + getSubstrateDictionaryBundle: jest.fn().mockResolvedValue({ + categories: [], + substrateTypes: [], + processTypes: [], + }), + }, +})) + +jest.mock('@/server/services/material.service', () => ({ + materialService: { + findAll: jest.fn().mockResolvedValue({ items: [] }), + findAllCategories: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/category.service', () => ({ + categoryService: { + listActive: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/intake-node-permission-settings.service', () => ({ + intakeNodePermissionSettingsService: { + getSettings: jest.fn().mockResolvedValue({ overrides: {} }), + }, +})) + +jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ + intakeNonIntegratedProductService: { + listPage: jest.fn().mockResolvedValue({ + products: [], + pageInfo: { + page: 1, + pageSize: 50, + total: 0, + totalPages: 1, + }, + }), + }, +})) + jest.mock('@/server/services', () => ({ draftGroupService: { list: jest.fn().mockResolvedValue([ @@ -114,10 +222,23 @@ jest.mock('@/server/services', () => ({ jest.mock('../intake/IntakeClient', () => ({ __esModule: true, - default: ({ eligibleDraftGroups }: { eligibleDraftGroups?: Array<{ id: string; name: string }> }) => ( + default: ({ + eligibleDraftGroups, + eligibleSkuList, + }: { + eligibleDraftGroups?: Array<{ id: string; name: string }> + eligibleSkuList?: Array<{ id: string; code: string; skuFields?: { skuCode: string } }> + }) => (
IntakeClient
{eligibleDraftGroups?.map((group) =>
{group.name}
)} + {eligibleSkuList?.map((sku) => ( +
+ {sku.code} + {' / '} + {sku.skuFields?.skuCode} +
+ ))}
), })) @@ -132,4 +253,56 @@ describe('IntakePage', () => { expect(screen.queryByText('已入库草稿')).not.toBeInTheDocument() expect(screen.queryByText('已归档草稿')).not.toBeInTheDocument() }) + + it('准 SKU 首屏编码优先读取结构化 basicInfo', async () => { + const draftGroupServiceMock = jest.requireMock( + '@/server/services/draft-group.service', + ).draftGroupService + + draftGroupServiceMock.list.mockResolvedValueOnce([ + { + id: 'draft-structured', + code: 'DG-STRUCTURED', + name: '结构化草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + ]) + draftGroupServiceMock.getById.mockResolvedValue({ + draftGroup: { + id: 'draft-structured', + code: 'DG-STRUCTURED', + name: '结构化草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + preSkus: [ + { + id: 'pre-sku-1', + sequence: 1, + defaultName: '默认准 SKU', + customName: null, + status: 'PACKAGING', + componentCodes: [], + basicInfo: { + formalSkuCode: 'STRUCTURED-SKU', + }, + displaySummary: { + skuCode: 'LEGACY-SKU', + }, + familySelection: {}, + variantSelection: { + slots: [], + hardwareSelections: [], + }, + bomPreview: [], + }, + ], + }) + + render(await IntakePage({ searchParams: Promise.resolve({}) })) + + expect(screen.getByText('STRUCTURED-SKU / STRUCTURED-SKU')).toBeInTheDocument() + expect(screen.queryByText(/LEGACY-SKU/)).not.toBeInTheDocument() + }) }) diff --git a/furniture-3d-system/src/app/api/approval-workflows/route.test.ts b/furniture-3d-system/src/app/api/approval-workflows/route.test.ts index babc7657..31b918a5 100644 --- a/furniture-3d-system/src/app/api/approval-workflows/route.test.ts +++ b/furniture-3d-system/src/app/api/approval-workflows/route.test.ts @@ -21,7 +21,7 @@ jest.mock('@/server/services/approval-workflow.service', () => ({ }, })) -jest.mock('@/server/services', () => ({ +jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ intakeNonIntegratedProductService: { getById: (...args: unknown[]) => mockGetById(...args), }, diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts index 297275cc..e81824c3 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts @@ -49,6 +49,14 @@ export async function PUT(request: NextRequest, context: RouteContext) { typeof (rawBody as { workspaceCategory?: unknown }).workspaceCategory === 'string' ? ((rawBody as { workspaceCategory: string }).workspaceCategory.trim() || null) : null + const formalSpuAssignmentProductCategory = + rawBody && + typeof rawBody === 'object' && + typeof (rawBody as { formalSpuAssignmentProductCategory?: unknown }) + .formalSpuAssignmentProductCategory === 'string' + ? ((rawBody as { formalSpuAssignmentProductCategory: string }) + .formalSpuAssignmentProductCategory.trim() || null) + : null if (!product || typeof product !== 'object' || Array.isArray(product)) { return jsonError( @@ -66,7 +74,7 @@ export async function PUT(request: NextRequest, context: RouteContext) { id: decodedId, }, ctx.user, - { workspaceCategory }, + { workspaceCategory, formalSpuAssignmentProductCategory }, ) return jsonSuccess({ product: savedProduct }, 200, ctx.requestId) } catch (error) { diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index 85ebcc32..17dd1818 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -303,7 +303,7 @@ const packagingMaterialSchema = z.object({ assetFamilyId: z.string().trim().min(1).optional().nullable(), code: z.string().trim().min(1).optional().nullable(), name: z.string().trim().min(1).optional().nullable(), - quantity: z.number().min(0), + quantity: z.number().positive(), unitCost: z.number().optional().nullable(), totalCost: z.number().optional().nullable(), notes: z.string().optional().nullable(), @@ -319,23 +319,23 @@ const packagingBoxSchema = z.object({ }), items: z.array(packagingBoxItemSchema), materials: z.array(packagingMaterialSchema), - estimatedWeightKg: z.number().optional().nullable().default(null), - estimatedCost: z.number().optional().nullable().default(null), + estimatedWeightKg: z.number().nonnegative().optional().nullable().default(null), + estimatedCost: z.number().nonnegative().optional().nullable().default(null), notes: z.string().nullable().default(null), }) const packagingCostSummarySchema = z.object({ - materialCost: z.number().optional().nullable().default(null), - totalCost: z.number().optional().nullable().default(null), - totalWeightKg: z.number().optional().nullable().default(null), - totalVolumeM3: z.number().optional().nullable().default(null), + materialCost: z.number().nonnegative().optional().nullable().default(null), + totalCost: z.number().nonnegative().optional().nullable().default(null), + totalWeightKg: z.number().nonnegative().optional().nullable().default(null), + totalVolumeM3: z.number().nonnegative().optional().nullable().default(null), notes: z.string().nullable().default(null), }) export const savePreSkuPackagingPlanSchema = z.object({ sourceType: z.enum(['PRODUCT', 'PRE_SKU', 'DRAFT_GROUP']).optional(), sourceRefId: z.string().trim().optional().nullable(), - status: z.string().trim().optional(), + status: z.enum(['DRAFT', 'CONFIRMED', 'ARCHIVED']).optional(), boxes: z.array(packagingBoxSchema), costSummary: packagingCostSummarySchema.optional().nullable(), processDraft: z.record(z.string(), z.unknown()).optional().nullable(), @@ -441,6 +441,22 @@ export const formalizeDraftGroupSchema = z.object({ message: '新建 SPU 时必须选择产品品类', }) } + + if (value.targetMode === 'CREATE_PRODUCT' && !value.modelNumber?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['modelNumber'], + message: '新建 SPU 时必须填写型号', + }) + } + + if (value.targetMode === 'CREATE_PRODUCT' && !value.productName?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['productName'], + message: '新建 SPU 时必须填写产品名称', + }) + } }) export const warehouseLocationCreateSchema = z.object({ diff --git a/furniture-3d-system/src/app/api/trash/products/[id]/route.ts b/furniture-3d-system/src/app/api/trash/products/[id]/route.ts index 88a4a47a..236e815e 100644 --- a/furniture-3d-system/src/app/api/trash/products/[id]/route.ts +++ b/furniture-3d-system/src/app/api/trash/products/[id]/route.ts @@ -14,6 +14,7 @@ import { productService } from '@/server/services/product.service' import { timeAsync } from '@/server/monitoring/performance' import { AppError, ErrorCode } from '@/server/core/errors' import { prisma } from '@/lib/prisma' +import { logActivity } from '@/server/services/logger.service' /** * POST /api/trash/products/[id] @@ -49,16 +50,14 @@ export async function POST( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'PRODUCT_UPDATE', - targetType: 'Product', - targetId: id, - action: `从回收站恢复产品`, - metadata: { - restoredSkuCount: result.restoredSkuCount, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'PRODUCT_UPDATE', + targetType: 'Product', + targetId: id, + action: `从回收站恢复产品`, + metadata: { + restoredSkuCount: result.restoredSkuCount, }, }) @@ -110,19 +109,17 @@ export async function DELETE( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'PRODUCT_DELETE', - targetType: 'Product', - targetId: id, - action: `彻底删除产品: ${product.modelNumber} - ${product.name}`, - metadata: { - modelNumber: product.modelNumber, - name: product.name, - deletedSkuCount: result.deletedSkuCount, - permanent: true, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'PRODUCT_DELETE', + targetType: 'Product', + targetId: id, + action: `彻底删除产品: ${product.modelNumber} - ${product.name}`, + metadata: { + modelNumber: product.modelNumber, + name: product.name, + deletedSkuCount: result.deletedSkuCount, + permanent: true, }, }) diff --git a/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts b/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts index 5ea0eaa5..42240662 100644 --- a/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts +++ b/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts @@ -14,6 +14,7 @@ import { skuService } from '@/server/services/sku.service' import { timeAsync } from '@/server/monitoring/performance' import { AppError, ErrorCode } from '@/server/core/errors' import { prisma } from '@/lib/prisma' +import { logActivity } from '@/server/services/logger.service' /** * POST /api/trash/skus/[id] @@ -38,17 +39,15 @@ export async function POST( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'SKU_UPDATE', - targetType: 'ProductSKU', - targetId: id, - action: `从回收站恢复 SKU: ${result.skuCode}`, - metadata: { - skuCode: result.skuCode, - productId: result.productId, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'SKU_UPDATE', + targetType: 'ProductSKU', + targetId: id, + action: `从回收站恢复 SKU: ${result.skuCode}`, + metadata: { + skuCode: result.skuCode, + productId: result.productId, }, }) @@ -97,18 +96,16 @@ export async function DELETE( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'SKU_DELETE', - targetType: 'ProductSKU', - targetId: id, - action: `彻底删除 SKU: ${sku.skuCode}`, - metadata: { - skuCode: sku.skuCode, - productId: sku.productId, - permanent: true, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'SKU_DELETE', + targetType: 'ProductSKU', + targetId: id, + action: `彻底删除 SKU: ${sku.skuCode}`, + metadata: { + skuCode: sku.skuCode, + productId: sku.productId, + permanent: true, }, }) diff --git a/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx b/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx index c12a2939..5ef81a4c 100644 --- a/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx +++ b/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx @@ -27,7 +27,6 @@ import { import { NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, NON_INTEGRATED_PRODUCTS_UPDATED, - NON_INTEGRATED_STORAGE_KEY, type NonIntegratedSkuComponentDetail, type NonIntegratedProductRecord, type NonIntegratedSkuItem, @@ -544,11 +543,6 @@ function normalizeSeriesMatchKey(value: string | null | undefined) { return (value ?? '').trim().replace(/\s+/g, ' ').toLowerCase() } -function readRecordString(record: Record, key: string) { - const value = record[key] - return typeof value === 'string' ? value.trim() : '' -} - function normalizeSkuBatchFlowNos(value: unknown): Record | undefined { if (!isRecord(value)) return undefined @@ -592,45 +586,6 @@ function buildFormalSeriesProductsFromRecords( }) } -function readFormalSeriesProducts(): FormalSeriesProductSummary[] { - if (typeof window === 'undefined') return [] - - try { - const rawValue = window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) - if (!rawValue) return [] - - const parsed = JSON.parse(rawValue) as unknown - if (!Array.isArray(parsed)) return [] - - return parsed.flatMap((item): FormalSeriesProductSummary[] => { - if (!isRecord(item)) return [] - const kind = readRecordString(item, 'productDevelopmentKind') - if (kind !== 'series') return [] - - const openingReference = isRecord(item.openingReference) ? item.openingReference : {} - const spu = readRecordString(item, 'spu').toUpperCase() - const name = - readRecordString(openingReference, 'productName') || - readRecordString(item, 'name') - const id = readRecordString(item, 'id') || spu - const skuItems = Array.isArray(item.skuItems) - ? (item.skuItems.filter(isRecord) as unknown as NonIntegratedSkuItem[]) - : [] - const pushedSourceSkuIds = Array.from(new Set( - skuItems - .map((skuItem) => skuItem.sourceSkuId?.trim() ?? '') - .filter(Boolean), - )) - const skuBatchFlowNos = normalizeSkuBatchFlowNos(item.skuBatchFlowNos) - - if (!spu || !name) return [] - return [{ id, name, spu, pushedSourceSkuIds, skuItems, skuBatchFlowNos }] - }) - } catch { - return [] - } -} - function mergeFormalSeriesProducts( left: FormalSeriesProductSummary[], right: FormalSeriesProductSummary[], @@ -2758,13 +2713,6 @@ export default function DraftGroupsFormalClient({ } const refreshFormalSeriesProducts = () => { - const localProducts = readFormalSeriesProducts() - if (localProducts.length > 0) { - setFormalSeriesProducts( - mergeFormalSeriesProducts(initialFormalSeriesProducts, localProducts), - ) - } - void (async () => { try { const productPage = await listSharedNonIntegratedProductPage({ @@ -2776,7 +2724,7 @@ export default function DraftGroupsFormalClient({ buildFormalSeriesProductsFromRecords(productPage.products), ) } catch { - // Keep the latest local snapshot when the product API is temporarily unavailable. + // Keep server-provided formal products when the product API is temporarily unavailable. } })() } diff --git a/furniture-3d-system/src/app/home-client.types.ts b/furniture-3d-system/src/app/home-client.types.ts index 9ad464ef..a9ea5649 100644 --- a/furniture-3d-system/src/app/home-client.types.ts +++ b/furniture-3d-system/src/app/home-client.types.ts @@ -6,7 +6,8 @@ export interface ProductSku { price: number stock: number isActive: boolean - attributes?: Record | null + legacyAttributes?: Record | null + displayTags?: string[] } export interface ProductWithSkus { diff --git a/furniture-3d-system/src/app/home/home-client.types.ts b/furniture-3d-system/src/app/home/home-client.types.ts index 9854b7b6..0f79ceb7 100644 --- a/furniture-3d-system/src/app/home/home-client.types.ts +++ b/furniture-3d-system/src/app/home/home-client.types.ts @@ -6,7 +6,8 @@ export interface ProductSku { price: number stock: number isActive: boolean - attributes?: Record | null + legacyAttributes?: Record | null + displayTags?: string[] } export interface ProductWithSkus { diff --git a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx index 09e5776a..163cc508 100644 --- a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx @@ -15,10 +15,14 @@ import { import Modal from '@/components/ui/Modal' import { ACCESSORY_LIBRARY_CHANGED_EVENT, - ACCESSORY_LIBRARY_STORAGE_KEY, - readAccessoryLibraryItems, + ACCESSORY_REFERENCE_LIBRARY_TYPE, + decodeAccessoryReferenceLibraryRows, type AccessoryLibraryItem, } from '@/lib/accessory-library' +import { + listIntakeReferenceLibraryItems, + replaceIntakeReferenceLibraryItems, +} from '@/lib/intake-reference-library-client' const ACCESSORY_LIBRARY_IMAGE_MAX_SIZE_BYTES = 1.5 * 1024 * 1024 const ACCESSORY_LIBRARY_LETTERS = Array.from({ length: 26 }, (_, index) => @@ -1583,8 +1587,20 @@ function normalizeScrewItem(item: AccessoryLibraryItem): ScrewItem { } } -function readScrewItems() { - return readAccessoryLibraryItems().map(normalizeScrewItem) +function buildAccessoryReferenceLibraryRows(items: ScrewItem[]) { + return items.map((item, index) => ({ + itemKey: item.code.trim().toUpperCase(), + code: item.code, + name: item.name, + category: item.screwCategory, + letter: item.letter, + payload: { + kind: ACCESSORY_REFERENCE_LIBRARY_TYPE, + item, + }, + sortOrder: index, + isActive: true, + })) } function buildNextScrewCode( @@ -2200,7 +2216,7 @@ export default function AccessoryLibraryPanel({ canEditItems = true, canDeleteItems = false, }: AccessoryLibraryPanelProps) { - const [items, setItems] = useState(readScrewItems) + const [items, setItems] = useState([]) const [selectedLetter, setSelectedLetter] = useState('A') const [activeHeadType, setActiveHeadType] = useState('全部') @@ -2214,18 +2230,68 @@ export default function AccessoryLibraryPanel({ useState(EMPTY_FILTERS) const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS) + const [isServerLoading, setIsServerLoading] = useState(true) + const [isServerSaving, setIsServerSaving] = useState(false) + const [serverError, setServerError] = useState('') + const [serverNotice, setServerNotice] = useState('') useEffect(() => { - try { - window.localStorage.setItem( - ACCESSORY_LIBRARY_STORAGE_KEY, - JSON.stringify(items), - ) - window.dispatchEvent(new Event(ACCESSORY_LIBRARY_CHANGED_EVENT)) - } catch { - console.warn('配件资料库本地缓存空间不足,未能完成持久化保存。') + let isCancelled = false + setIsServerLoading(true) + setServerError('') + setServerNotice('') + + listIntakeReferenceLibraryItems(ACCESSORY_REFERENCE_LIBRARY_TYPE) + .then((rows) => { + if (isCancelled) return + const serverItems = decodeAccessoryReferenceLibraryRows(rows).map( + normalizeScrewItem, + ) + setItems(serverItems) + setServerNotice( + serverItems.length ? '' : '服务端暂无配件资料,新增后写入服务端资料库', + ) + }) + .catch((error) => { + if (isCancelled) return + setServerError(error instanceof Error ? error.message : '配件资料库读取失败') + }) + .finally(() => { + if (!isCancelled) { + setIsServerLoading(false) + } + }) + + return () => { + isCancelled = true } - }, [items]) + }, []) + + async function commitAccessoryLibrary(nextItems: ScrewItem[]) { + setIsServerSaving(true) + setServerError('') + setServerNotice('') + + try { + const savedRows = await replaceIntakeReferenceLibraryItems( + ACCESSORY_REFERENCE_LIBRARY_TYPE, + buildAccessoryReferenceLibraryRows(nextItems), + ) + const savedItems = decodeAccessoryReferenceLibraryRows(savedRows) + const normalizedItems = savedItems.map(normalizeScrewItem) + setItems(normalizedItems) + window.dispatchEvent(new Event(ACCESSORY_LIBRARY_CHANGED_EVENT)) + return normalizedItems + } catch (error) { + const message = + error instanceof Error ? error.message : '配件资料库保存失败' + setServerError(message) + setEditingError(message) + return null + } finally { + setIsServerSaving(false) + } + } const selectedLetterItems = useMemo( () => @@ -2404,7 +2470,7 @@ export default function AccessoryLibraryPanel({ } function openCreateModal(source?: ScrewItem) { - if (!canEditItems) return + if (!canEditItems || isServerSaving) return setEditingId(null) setCopySource(source ?? null) @@ -2413,7 +2479,7 @@ export default function AccessoryLibraryPanel({ } function openEditModal(item: ScrewItem) { - if (!canEditItems) return + if (!canEditItems || isServerSaving) return setEditingId(item.id) setCopySource(null) @@ -2482,7 +2548,7 @@ export default function AccessoryLibraryPanel({ } async function handleDraftImageChange(event: ChangeEvent) { - if (!canEditItems) return + if (!canEditItems || isServerSaving) return const file = event.target.files?.[0] if (!file || !draft) return @@ -2523,7 +2589,7 @@ export default function AccessoryLibraryPanel({ } function clearDraftImage() { - if (!canEditItems) return + if (!canEditItems || isServerSaving) return if (!draft) return @@ -2534,8 +2600,8 @@ export default function AccessoryLibraryPanel({ }) } - function saveDraft() { - if (!canEditItems) return + async function saveDraft() { + if (!canEditItems || isServerSaving) return if (!draft) return @@ -2561,26 +2627,28 @@ export default function AccessoryLibraryPanel({ updatedAt: new Date().toISOString(), } - setItems((current) => { - if (editingId) { - return current.map((item) => (item.id === editingId ? nextItem : item)) - } - - return [...current, nextItem] - }) + const nextItems = editingId + ? items.map((item) => (item.id === editingId ? nextItem : item)) + : [...items, nextItem] + const savedItems = await commitAccessoryLibrary(nextItems) + if (!savedItems) return closeDraftModal() } - function removeItem(item: ScrewItem) { - if (!canDeleteItems) return + async function removeItem(item: ScrewItem) { + if (!canDeleteItems || isServerSaving) return const shouldDelete = window.confirm( `确认删除配件【${item.code} ${item.name}】吗?删除后 BOM 选择里也不会再带出这个配件。`, ) if (!shouldDelete) return - setItems((current) => current.filter((currentItem) => currentItem.id !== item.id)) + const savedItems = await commitAccessoryLibrary( + items.filter((currentItem) => currentItem.id !== item.id), + ) + if (!savedItems) return + if (previewItem?.id === item.id) { setPreviewItem(null) } @@ -2673,6 +2741,19 @@ export default function AccessoryLibraryPanel({ {hasActiveFilters && isSelectedLetterConfigured ? ( 筛选后显示 {visibleItems.length} 条 ) : null} + + {isServerLoading + ? '正在读取服务端资料库' + : serverError || serverNotice || '已接入服务端资料库'} + @@ -2728,6 +2809,7 @@ export default function AccessoryLibraryPanel({ type="button" className={PRIMARY_BUTTON_CLASS} onClick={() => openCreateModal()} + disabled={isServerSaving} > 新增 {selectedLetter} 组 @@ -2933,6 +3015,7 @@ export default function AccessoryLibraryPanel({ title="编辑" className={OPERATION_ICON_BUTTON_CLASS} onClick={() => openEditModal(item)} + disabled={isServerSaving} > @@ -2942,6 +3025,7 @@ export default function AccessoryLibraryPanel({ title="复制" className={OPERATION_ICON_BUTTON_CLASS} onClick={() => openCreateModal(item)} + disabled={isServerSaving} > @@ -2952,6 +3036,7 @@ export default function AccessoryLibraryPanel({ title="删除" className={DELETE_ICON_BUTTON_CLASS} onClick={() => removeItem(item)} + disabled={isServerSaving} > @@ -3013,8 +3098,9 @@ export default function AccessoryLibraryPanel({ type="button" className={PRIMARY_BUTTON_CLASS} onClick={saveDraft} + disabled={isServerSaving} > - 保存 + {isServerSaving ? '保存中' : '保存'} } diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx index 3431ed71..35578d63 100644 --- a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx @@ -2,7 +2,113 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import userEvent from '@testing-library/user-event' import AccessoryLibraryPanel from '@/app/intake/AccessoryLibraryPanel' import PackagingMaterialLibraryPanel from '@/app/intake/PackagingMaterialLibraryPanel' -import { ACCESSORY_LIBRARY_STORAGE_KEY } from '@/lib/accessory-library' + +const LEGACY_PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY = + 'formal-intake-packaging-material-library-items' + +function createApiResponse(data: unknown, init?: ResponseInit) { + const status = init?.status ?? 200 + return { + ok: status >= 200 && status < 300, + status, + json: async () => data, + } as Response +} + +function spyOnFetch() { + if (!('fetch' in global)) { + Object.defineProperty(global, 'fetch', { + configurable: true, + writable: true, + value: jest.fn(), + }) + } + const fetchSpy = jest.spyOn(global, 'fetch' as never) + fetchSpy.mockClear() + return fetchSpy +} + +function mockReferenceLibraryFetch(items: unknown[] = []) { + return spyOnFetch().mockResolvedValue( + createApiResponse({ + success: true, + data: { items }, + }), + ) +} + +function createReferenceLibraryRow(type: string, item: Record, index = 0) { + const now = '2026-05-30T00:00:00.000Z' + return { + id: `${type.toLowerCase()}-${String(item.id ?? item.code ?? index)}`, + libraryType: type, + itemKey: String(item.id ?? item.code ?? index), + code: String(item.code ?? ''), + name: String(item.name ?? ''), + category: String(item.screwCategory ?? ''), + letter: String(item.letter ?? ''), + payload: item, + sortOrder: index, + isActive: true, + createdBy: null, + updatedBy: null, + createdAt: now, + updatedAt: now, + } +} + +function mockReferenceLibraryFetchWithState(initialRows: unknown[] = []) { + let rows = [...initialRows] + + return spyOnFetch().mockImplementation(async (input, init) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if (url.includes('/api/intake/reference-library') && method === 'PUT') { + const body = JSON.parse(String(init?.body ?? '{}')) as { + type?: string + items?: Array> + } + rows = (body.items ?? []).map((item, index) => + createReferenceLibraryRow(body.type ?? 'ACCESSORY', { + ...(item.payload && typeof item.payload === 'object' + ? (item.payload as Record) + : {}), + id: item.itemKey, + code: item.code, + name: item.name, + letter: item.letter, + }, index), + ) + return createApiResponse({ success: true, data: { items: rows } }) + } + + return createApiResponse({ success: true, data: { items: rows } }) + }) +} + +function readLastReferenceLibraryPutBody(fetchSpy: jest.SpyInstance) { + const putCall = fetchSpy.mock.calls + .filter( + ([url, init]) => + String(url).includes('/api/intake/reference-library') && + (init as RequestInit | undefined)?.method === 'PUT', + ) + .at(-1) + expect(putCall).toBeDefined() + return JSON.parse(String((putCall?.[1] as RequestInit).body)) as { + type?: string + items?: Array<{ payload?: { item?: Record } }> + } +} + +async function waitAccessoryLibraryReady() { + return screen.findByText(/已接入服务端资料库|服务端暂无配件资料/) +} + +async function waitPackagingMaterialLibraryReady() { + return screen.findByText(/已接入服务端资料库|服务端暂无包材资料/) +} describe('alphabet library panels', () => { beforeEach(() => { @@ -15,8 +121,10 @@ describe('alphabet library panels', () => { it('uses the structured self-tapping screw library for accessories', async () => { const actor = userEvent.setup() + const fetchSpy = mockReferenceLibraryFetchWithState() render() + await waitAccessoryLibraryReady() expect(screen.getByText('A 自攻螺丝')).toBeInTheDocument() expect(screen.queryByRole('button', { name: '新增' })).not.toBeInTheDocument() @@ -43,10 +151,8 @@ describe('alphabet library panels', () => { ).toBeInTheDocument() }) - const savedItems = JSON.parse( - window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', - ) - expect(savedItems[0]).toMatchObject({ + const savedBody = readLastReferenceLibraryPutBody(fetchSpy) + expect(savedBody.items?.[0]?.payload?.item).toMatchObject({ letter: 'A', code: 'A0', name: 'M4*20 十字 沉头 尖尾 自攻螺丝', @@ -63,8 +169,10 @@ describe('alphabet library panels', () => { it('lets unconfigured accessory letter groups be selected', async () => { const actor = userEvent.setup() + mockReferenceLibraryFetchWithState() render() + await waitAccessoryLibraryReady() await actor.click(screen.getByRole('button', { name: 'O 0 条' })) @@ -80,8 +188,10 @@ describe('alphabet library panels', () => { it('uses the connection screw rod rules for C accessories', async () => { const actor = userEvent.setup() + const fetchSpy = mockReferenceLibraryFetchWithState() render() + await waitAccessoryLibraryReady() await actor.click(screen.getByRole('button', { name: 'C 连接螺杆 0 条' })) @@ -122,10 +232,8 @@ describe('alphabet library panels', () => { expect(screen.getByText('M8*70 尖平尾 双头牙')).toBeInTheDocument() }) - const savedItems = JSON.parse( - window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', - ) - expect(savedItems[0]).toMatchObject({ + const savedBody = readLastReferenceLibraryPutBody(fetchSpy) + expect(savedBody.items?.[0]?.payload?.item).toMatchObject({ letter: 'C', code: 'C0', name: 'M8*70 尖平尾 双头牙', @@ -891,8 +999,10 @@ describe('alphabet library panels', () => { it('filters C and D accessory groups by category shortcuts', async () => { const actor = userEvent.setup() + mockReferenceLibraryFetchWithState() render() + await waitAccessoryLibraryReady() await actor.click(screen.getByRole('button', { name: 'C 连接螺杆 0 条' })) await actor.click(screen.getByRole('button', { name: '新增 C 组' })) @@ -936,8 +1046,10 @@ describe('alphabet library panels', () => { it('uses whole-set eccentric connector rules for D accessories', async () => { const actor = userEvent.setup() + const fetchSpy = mockReferenceLibraryFetchWithState() render() + await waitAccessoryLibraryReady() await actor.click(screen.getByRole('button', { name: 'D 偏心连接件 0 条' })) @@ -983,10 +1095,8 @@ describe('alphabet library panels', () => { expect(screen.getAllByText('双头偏心连接杆').length).toBeGreaterThan(0) expect(screen.getAllByText('42mm').length).toBeGreaterThan(0) - const savedItems = JSON.parse( - window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', - ) - expect(savedItems[0]).toMatchObject({ + const savedBody = readLastReferenceLibraryPutBody(fetchSpy) + expect(savedBody.items?.[0]?.payload?.item).toMatchObject({ letter: 'D', code: 'D0', name: 'M8*42 镀锌 双头偏心连接杆', @@ -1004,8 +1114,10 @@ describe('alphabet library panels', () => { it('opens copy-new mode and blocks exact duplicate screw configs', async () => { const actor = userEvent.setup() + mockReferenceLibraryFetchWithState() render() + await waitAccessoryLibraryReady() await actor.click(screen.getByRole('button', { name: '新增 A 组' })) let dialog = await screen.findByRole('dialog', { name: '新增A组配件' }) @@ -1038,8 +1150,10 @@ describe('alphabet library panels', () => { it('shows delete only when accessory library deletion is allowed', async () => { const actor = userEvent.setup() const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true) + const fetchSpy = mockReferenceLibraryFetchWithState() const { rerender } = render() + await waitAccessoryLibraryReady() await actor.click(screen.getByRole('button', { name: '新增 A 组' })) let dialog = await screen.findByRole('dialog', { name: '新增A组配件' }) @@ -1063,13 +1177,15 @@ describe('alphabet library panels', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.stringContaining('确认删除配件'), ) - expect( - JSON.parse(window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]'), - ).toEqual([]) + const savedBody = readLastReferenceLibraryPutBody(fetchSpy) + expect(savedBody.items).toEqual([]) }) - it('keeps accessory library read-only when editing is not allowed', () => { + it('keeps accessory library read-only when editing is not allowed', async () => { + mockReferenceLibraryFetchWithState() + render() + await waitAccessoryLibraryReady() expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '编辑' })).not.toBeInTheDocument() @@ -1080,9 +1196,11 @@ describe('alphabet library panels', () => { it('keeps the packaging material library on its original fields', async () => { const actor = userEvent.setup() + mockReferenceLibraryFetch() render() + await waitPackagingMaterialLibraryReady() await actor.click(screen.getByRole('button', { name: '新增当前字母' })) const dialog = await screen.findByRole('dialog', { name: '新增包材' }) @@ -1092,10 +1210,191 @@ describe('alphabet library panels', () => { expect(within(dialog).queryByLabelText('材质')).not.toBeInTheDocument() }) - it('keeps packaging material library read-only when editing is not allowed', () => { + it('keeps packaging material library read-only when editing is not allowed', async () => { + mockReferenceLibraryFetch() + render() + await waitPackagingMaterialLibraryReady() expect(screen.queryByRole('button', { name: '新增当前字母' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '新增' })).not.toBeInTheDocument() }) + + it('loads packaging material library from the server without mirroring it to browser storage', async () => { + mockReferenceLibraryFetch([ + { + id: 'server-packaging-a0', + libraryType: 'PACKAGING_MATERIAL', + itemKey: 'pkg-a0', + code: 'A0', + name: '蜂窝纸箱', + category: '', + letter: 'A', + payload: { + id: 'pkg-a0', + letter: 'A', + code: 'A0', + name: '蜂窝纸箱', + price: '3.5', + imageUrl: '/packaging/a0.png', + imageName: 'a0.png', + updatedAt: '2026-05-30T00:00:00.000Z', + }, + sortOrder: 0, + isActive: true, + createdBy: null, + updatedBy: null, + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + }, + ]) + + render() + + expect(await screen.findByText('蜂窝纸箱')).toBeInTheDocument() + expect(window.localStorage.length).toBe(0) + }) + + it('ignores legacy packaging browser cache when the server library is empty', async () => { + const fetchSpy = mockReferenceLibraryFetch() + window.localStorage.setItem( + LEGACY_PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY, + JSON.stringify([ + { + id: 'local-pkg-a0', + letter: 'A', + code: 'A0', + name: '本地泡沫', + price: '0.8', + imageUrl: '', + imageName: '', + updatedAt: '2026-05-30T00:00:00.000Z', + }, + ]), + ) + + render() + + expect( + await screen.findByText('服务端暂无包材资料,新增后写入服务端资料库'), + ).toBeInTheDocument() + expect(screen.queryByText('本地泡沫')).not.toBeInTheDocument() + expect(fetchSpy).toHaveBeenCalledWith( + '/api/intake/reference-library?type=PACKAGING_MATERIAL', + expect.objectContaining({ + headers: expect.objectContaining({ Accept: 'application/json' }), + }), + ) + expect( + fetchSpy.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'PUT'), + ).toBe(false) + }) + + it('saves packaging material library changes only through the server', async () => { + const actor = userEvent.setup() + const fetchSpy = spyOnFetch() + fetchSpy.mockResolvedValueOnce(createApiResponse({ success: true, data: { items: [] } })) + fetchSpy.mockResolvedValueOnce( + createApiResponse({ + success: true, + data: { + items: [ + { + id: 'server-packaging-a0', + libraryType: 'PACKAGING_MATERIAL', + itemKey: 'pkg-a0', + code: 'A0', + name: '护角', + category: '', + letter: 'A', + payload: { + id: 'pkg-a0', + letter: 'A', + code: 'A0', + name: '护角', + price: '1.2', + imageUrl: '', + imageName: '', + updatedAt: '2026-05-30T00:00:00.000Z', + }, + sortOrder: 0, + isActive: true, + createdBy: null, + updatedBy: null, + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + }, + ], + }, + }), + ) + + render() + + await waitPackagingMaterialLibraryReady() + await actor.click(screen.getByRole('button', { name: '新增当前字母' })) + const dialog = await screen.findByRole('dialog', { name: '新增包材' }) + fireEvent.change(within(dialog).getByLabelText(/包材名称/), { + target: { value: '护角' }, + }) + fireEvent.change(within(dialog).getByLabelText('价格'), { + target: { value: '1.2' }, + }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(fetchSpy).toHaveBeenLastCalledWith( + '/api/intake/reference-library', + expect.objectContaining({ + method: 'PUT', + }), + ) + }) + const requestBody = JSON.parse(String((fetchSpy.mock.calls.at(-1)?.[1] as RequestInit).body)) + expect(requestBody).toMatchObject({ + type: 'PACKAGING_MATERIAL', + items: [ + { + itemKey: 'A0', + code: 'A0', + name: '护角', + letter: 'A', + payload: expect.objectContaining({ + code: 'A0', + name: '护角', + price: '1.2', + }), + }, + ], + }) + expect(window.localStorage.length).toBe(0) + }) + + it('does not pretend packaging material changes were saved when the server write fails', async () => { + const actor = userEvent.setup() + const fetchSpy = spyOnFetch() + fetchSpy.mockResolvedValueOnce(createApiResponse({ success: true, data: { items: [] } })) + fetchSpy.mockResolvedValueOnce( + createApiResponse( + { + success: false, + error: { message: '资料库记录 code 重复:A0' }, + }, + { status: 400 }, + ), + ) + + render() + + await waitPackagingMaterialLibraryReady() + await actor.click(screen.getByRole('button', { name: '新增当前字母' })) + const dialog = await screen.findByRole('dialog', { name: '新增包材' }) + fireEvent.change(within(dialog).getByLabelText(/包材名称/), { + target: { value: '护角' }, + }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + expect(await within(dialog).findByText('资料库记录 code 重复:A0')).toBeInTheDocument() + expect(window.localStorage.length).toBe(0) + }) }) diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx index 447b6023..b510a05a 100644 --- a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.tsx @@ -6,6 +6,12 @@ import { Eye, ImageIcon, Plus, Search, Upload } from 'lucide-react' import Button from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import Modal from '@/components/ui/Modal' +import { + listAlphabetReferenceLibraryItems, + replaceAlphabetReferenceLibraryItems, + type AlphabetReferenceLibraryItem, + type IntakeReferenceLibraryType, +} from '@/lib/intake-reference-library-client' const ALPHABET_LIBRARY_IMAGE_MAX_SIZE_BYTES = 1.5 * 1024 * 1024 const ALPHABET_LIBRARY_LETTERS = Array.from({ length: 26 }, (_, index) => @@ -49,12 +55,12 @@ type AlphabetLibraryExtraFieldDefinition = { } interface AlphabetLibraryPanelProps { - storageKey: string entityName: string canEditItems?: boolean queryNamePlaceholder?: string changedEventName?: string extraFields?: AlphabetLibraryExtraFieldDefinition[] + serverLibraryType: IntakeReferenceLibraryType } function createItemId() { @@ -69,37 +75,23 @@ function normalizeText(value: string) { return value.trim().toLowerCase() } -function readLibraryItems(storageKey: string) { - if (typeof window === 'undefined') { - return [] as AlphabetLibraryItem[] - } - - try { - const rawValue = window.localStorage.getItem(storageKey) - if (!rawValue) return [] - - const parsedValue = JSON.parse(rawValue) - if (!Array.isArray(parsedValue)) return [] - - return parsedValue.filter((item): item is AlphabetLibraryItem => { - return ( - item && - typeof item.id === 'string' && - typeof item.letter === 'string' && - ALPHABET_LIBRARY_LETTERS.includes(item.letter as AlphabetLibraryLetter) && - typeof item.code === 'string' && - typeof item.name === 'string' && - (item.color === undefined || typeof item.color === 'string') && - (item.material === undefined || typeof item.material === 'string') && - typeof item.price === 'string' && - typeof item.imageUrl === 'string' && - typeof item.imageName === 'string' && - typeof item.updatedAt === 'string' - ) - }) - } catch { - return [] - } +function normalizeAlphabetLibraryItems(items: AlphabetReferenceLibraryItem[]) { + return items.filter((item): item is AlphabetLibraryItem => { + return ( + item && + typeof item.id === 'string' && + typeof item.letter === 'string' && + ALPHABET_LIBRARY_LETTERS.includes(item.letter as AlphabetLibraryLetter) && + typeof item.code === 'string' && + typeof item.name === 'string' && + (item.color === undefined || typeof item.color === 'string') && + (item.material === undefined || typeof item.material === 'string') && + typeof item.price === 'string' && + typeof item.imageUrl === 'string' && + typeof item.imageName === 'string' && + typeof item.updatedAt === 'string' + ) + }) } function readLibraryCodeIndex(code: string, letter: AlphabetLibraryLetter) { @@ -149,14 +141,18 @@ function createDraft(items: AlphabetLibraryItem[], letter: AlphabetLibraryLetter } export default function AlphabetLibraryPanel({ - storageKey, entityName, canEditItems = true, queryNamePlaceholder, changedEventName, extraFields = [], + serverLibraryType, }: AlphabetLibraryPanelProps) { - const [items, setItems] = useState(() => readLibraryItems(storageKey)) + const [items, setItems] = useState([]) + const [isServerLoading, setIsServerLoading] = useState(true) + const [isServerSaving, setIsServerSaving] = useState(false) + const [serverError, setServerError] = useState('') + const [serverNotice, setServerNotice] = useState('') const [selectedLetter, setSelectedLetter] = useState('A') const [queryCode, setQueryCode] = useState('') const [queryName, setQueryName] = useState('') @@ -168,15 +164,34 @@ export default function AlphabetLibraryPanel({ const [previewItem, setPreviewItem] = useState(null) useEffect(() => { - try { - window.localStorage.setItem(storageKey, JSON.stringify(items)) - if (changedEventName) { - window.dispatchEvent(new Event(changedEventName)) - } - } catch { - console.warn(`${entityName}资料库本地缓存空间不足,未能完成持久化保存。`) + let isCancelled = false + setIsServerLoading(true) + setServerError('') + setServerNotice('') + + listAlphabetReferenceLibraryItems(serverLibraryType) + .then((serverItems) => { + if (isCancelled) return + const normalizedItems = normalizeAlphabetLibraryItems(serverItems) + setItems(normalizedItems) + setServerNotice( + normalizedItems.length > 0 ? '' : `服务端暂无${entityName}资料,新增后写入服务端资料库`, + ) + }) + .catch((error) => { + if (isCancelled) return + setServerError(error instanceof Error ? error.message : `${entityName}资料库读取失败`) + }) + .finally(() => { + if (!isCancelled) { + setIsServerLoading(false) + } + }) + + return () => { + isCancelled = true } - }, [changedEventName, entityName, items, storageKey]) + }, [entityName, serverLibraryType]) const filteredItems = useMemo(() => { const codeKeyword = normalizeText(appliedQueryCode) @@ -321,10 +336,15 @@ export default function AlphabetLibraryPanel({ }) } - function saveDraft() { + async function persistItems(nextItems: AlphabetLibraryItem[]) { + const savedItems = await replaceAlphabetReferenceLibraryItems(serverLibraryType, nextItems) + return normalizeAlphabetLibraryItems(savedItems) + } + + async function saveDraft() { if (!canEditItems) return - if (!draft) return + if (!draft || isServerSaving) return const normalizedCode = draft.code.trim().toUpperCase() const normalizedName = draft.name.trim() @@ -365,22 +385,32 @@ export default function AlphabetLibraryPanel({ updatedAt: new Date().toISOString(), } - setItems((current) => { - if (editingId) { - return current.map((item) => (item.id === editingId ? nextItem : item)) + const nextItems = editingId + ? items.map((item) => (item.id === editingId ? nextItem : item)) + : [...items, nextItem] + + setIsServerSaving(true) + try { + const savedItems = await persistItems(nextItems) + setItems(savedItems) + setSelectedLetter(draft.letter) + setDraft(null) + setEditingId(null) + setEditingError('') + setServerError('') + setServerNotice('') + if (changedEventName && typeof window !== 'undefined') { + window.dispatchEvent(new Event(changedEventName)) } - - return [...current, nextItem] - }) - - setSelectedLetter(draft.letter) - setDraft(null) - setEditingId(null) - setEditingError('') + } catch (error) { + setEditingError(error instanceof Error ? error.message : `${entityName}资料库保存失败`) + } finally { + setIsServerSaving(false) + } } - function removeItem(id: string) { - if (!canEditItems) return + async function removeItem(id: string) { + if (!canEditItems || isServerSaving) return const target = items.find((item) => item.id === id) if (!target) return @@ -388,7 +418,21 @@ export default function AlphabetLibraryPanel({ const shouldDelete = window.confirm(`确认删除${entityName}【${target.code} ${target.name}】吗?`) if (!shouldDelete) return - setItems((current) => current.filter((item) => item.id !== id)) + const nextItems = items.filter((item) => item.id !== id) + setIsServerSaving(true) + try { + const savedItems = await persistItems(nextItems) + setItems(savedItems) + setServerError('') + setServerNotice('') + if (changedEventName && typeof window !== 'undefined') { + window.dispatchEvent(new Event(changedEventName)) + } + } catch (error) { + setServerError(error instanceof Error ? error.message : `${entityName}资料库删除失败`) + } finally { + setIsServerSaving(false) + } } return ( @@ -439,6 +483,9 @@ export default function AlphabetLibraryPanel({ 当前共 {items.length} 条{entityName}资料 当前字母 {activeLetter} 共 {totalGroupedItems[activeLetter]?.length ?? 0} 条 {hasActiveQuery ? 筛选后显示 {filteredItems.length} 条 : null} + + {isServerLoading ? '正在读取服务端资料库' : serverError || serverNotice || '已接入服务端资料库'} + @@ -677,8 +724,9 @@ export default function AlphabetLibraryPanel({ type="button" className={LIBRARY_PRIMARY_BUTTON_CLASS} onClick={saveDraft} + disabled={isServerSaving} > - 保存 + {isServerSaving ? '保存中' : '保存'} } diff --git a/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx b/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx index 1d46ac31..e3b00422 100644 --- a/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx @@ -5,16 +5,22 @@ import { Copy, Layers3, Plus, Ruler, Save, Search, Trash2 } from 'lucide-react' import Button from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { + EDGE_BANDING_LIBRARY_CHANGED_EVENT, + EDGE_BANDING_REFERENCE_ITEM_CATEGORY, + EDGE_BANDING_REFERENCE_LIBRARY_TYPE, buildEdgeBandingDisplayName, buildEdgeBandingLibraryItems, + buildEdgeBandingReferenceItemKey, createEdgeBandingLibraryItem, createEmptyEdgeBandingDraft, parseBoardThicknessMmFromText, - persistEdgeBandingLibraryItems, - readEdgeBandingLibraryItems, type EdgeBandingLibraryDraft, type EdgeBandingLibraryItem, } from '@/lib/edge-banding-library' +import { + listIntakeReferenceLibraryItems, + replaceIntakeReferenceLibraryItems, +} from '@/lib/intake-reference-library-client' type EditorMode = 'view' | 'create' | 'edit' | 'createCategory' @@ -28,8 +34,7 @@ type EdgeBandingLibraryCategory = { createdAt: string } -const EDGE_BANDING_CATEGORY_STORAGE_KEY = - 'formal-intake-edge-banding-library-categories' +const EDGE_BANDING_REFERENCE_CATEGORY_CATEGORY = 'CATEGORY' const LIBRARY_PRIMARY_BUTTON_CLASS = '!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400' const EDGE_BANDING_TOP_FIELD_CLASS = @@ -144,6 +149,22 @@ function getEdgeBandingGroupKey(item: EdgeBandingLibraryItem) { ) } +function normalizeEdgeBandingReferenceKeyPart(value: string) { + return normalizeDisplayText(value) + .toUpperCase() + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'UNKNOWN' +} + +function buildEdgeBandingReferenceCategoryKey(category: EdgeBandingLibraryCategory) { + return [ + 'CATEGORY', + normalizeEdgeBandingReferenceKeyPart(category.edgeThicknessText || 'UNKNOWN'), + normalizeEdgeBandingReferenceKeyPart(category.name), + ].join(':') +} + function createNormalizedDraftItem(draft: EdgeBandingLibraryDraft) { return createEdgeBandingLibraryItem(draft, { id: 'draft' }) } @@ -184,36 +205,96 @@ function createEdgeBandingCategory( } } -function readEdgeBandingCategories() { - if (typeof window === 'undefined') return [] +function readPayloadRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} - try { - const rawValue = window.localStorage.getItem(EDGE_BANDING_CATEGORY_STORAGE_KEY) - if (!rawValue) return [] - const parsedValue = JSON.parse(rawValue) - if (!Array.isArray(parsedValue)) return [] +function readPayloadText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} - return parsedValue - .map((item) => - createEdgeBandingCategory(String(item?.label ?? ''), { - id: typeof item?.id === 'string' ? item.id : undefined, - createdAt: - typeof item?.createdAt === 'string' ? item.createdAt : undefined, - }), +function decodeEdgeBandingReferenceLibraryRows( + rows: Awaited>, +) { + const items: EdgeBandingLibraryItem[] = [] + const categories: EdgeBandingLibraryCategory[] = [] + + rows.forEach((row) => { + const payload = readPayloadRecord(row.payload) + const kind = readPayloadText(payload.kind) || row.category + + if (kind === EDGE_BANDING_REFERENCE_CATEGORY_CATEGORY) { + const categoryPayload = readPayloadRecord(payload.category) + const category = createEdgeBandingCategory( + readPayloadText(categoryPayload.label) || row.name, + { + id: + readPayloadText(categoryPayload.id) || + row.itemKey.replace(/^category:/, ''), + createdAt: readPayloadText(categoryPayload.createdAt) || row.createdAt, + }, ) - .filter((item): item is EdgeBandingLibraryCategory => item !== null) - } catch { - return [] + if (category) categories.push(category) + return + } + + if (kind === EDGE_BANDING_REFERENCE_ITEM_CATEGORY) { + const itemPayload = readPayloadRecord(payload.item) + const item = createEdgeBandingLibraryItem(itemPayload, { + id: + readPayloadText(itemPayload.id) || + row.itemKey.replace(/^item:/, ''), + updatedAt: readPayloadText(itemPayload.updatedAt) || row.updatedAt, + }) + const [normalizedItem] = buildEdgeBandingLibraryItems([item]) + if (normalizedItem) items.push(normalizedItem) + } + }) + + return { + items, + categories, } } -function persistEdgeBandingCategories(categories: EdgeBandingLibraryCategory[]) { - if (typeof window === 'undefined') return +function buildEdgeBandingReferenceLibraryRows( + items: EdgeBandingLibraryItem[], + categories: EdgeBandingLibraryCategory[], +) { + const categoryRows = categories.map((category, index) => { + const itemKey = buildEdgeBandingReferenceCategoryKey(category) + return { + itemKey, + code: itemKey, + name: category.label, + category: EDGE_BANDING_REFERENCE_CATEGORY_CATEGORY, + payload: { + kind: EDGE_BANDING_REFERENCE_CATEGORY_CATEGORY, + category, + }, + sortOrder: index, + isActive: true, + } + }) - window.localStorage.setItem( - EDGE_BANDING_CATEGORY_STORAGE_KEY, - JSON.stringify(categories), - ) + const itemRows = buildEdgeBandingLibraryItems(items).map((item, index) => { + const itemKey = buildEdgeBandingReferenceItemKey(item) + return { + itemKey, + code: itemKey, + name: buildEdgeBandingDisplayName(item), + category: EDGE_BANDING_REFERENCE_ITEM_CATEGORY, + payload: { + kind: EDGE_BANDING_REFERENCE_ITEM_CATEGORY, + item, + }, + sortOrder: categoryRows.length + index, + isActive: true, + } + }) + + return [...categoryRows, ...itemRows] } export default function EdgeBandingLibraryPanel({ @@ -221,10 +302,8 @@ export default function EdgeBandingLibraryPanel({ }: { canEditItems?: boolean }) { - const initialItems = useMemo(() => readEdgeBandingLibraryItems(), []) - const initialCategories = useMemo(() => readEdgeBandingCategories(), []) - const [items, setItems] = useState(initialItems) - const [categories, setCategories] = useState(initialCategories) + const [items, setItems] = useState([]) + const [categories, setCategories] = useState([]) const [queryName, setQueryName] = useState('') const [queryEdgeThickness, setQueryEdgeThickness] = useState('') const [queryThickness, setQueryThickness] = useState('') @@ -235,32 +314,56 @@ export default function EdgeBandingLibraryPanel({ const [appliedQuerySpecWidth, setAppliedQuerySpecWidth] = useState('') const [categoryDraftName, setCategoryDraftName] = useState('') const [selectedItemId, setSelectedItemId] = useState( - initialItems[0]?.id ?? null, + null, ) + const [selectedGroupKey, setSelectedGroupKey] = useState(null) const [editorMode, setEditorMode] = useState('view') const [editingItemId, setEditingItemId] = useState(null) const [editorDraft, setEditorDraft] = useState( createEmptyEdgeBandingDraft(), ) const [panelMessage, setPanelMessage] = useState('') + const [isServerLoading, setIsServerLoading] = useState(true) + const [isServerSaving, setIsServerSaving] = useState(false) + const [serverError, setServerError] = useState('') + const [serverNotice, setServerNotice] = useState('') const normalizedItems = useMemo(() => buildEdgeBandingLibraryItems(items), [items]) useEffect(() => { - try { - persistEdgeBandingLibraryItems(items) - } catch { - console.warn('封边资料库本地缓存空间不足,当前修改暂未保存。') - } - }, [items]) + let isCancelled = false + setIsServerLoading(true) + setServerError('') + setServerNotice('') - useEffect(() => { - try { - persistEdgeBandingCategories(categories) - } catch { - console.warn('封边分类本地缓存空间不足,当前修改暂未保存。') + listIntakeReferenceLibraryItems(EDGE_BANDING_REFERENCE_LIBRARY_TYPE) + .then((rows) => { + if (isCancelled) return + const snapshot = decodeEdgeBandingReferenceLibraryRows(rows) + setItems(snapshot.items) + setCategories(snapshot.categories) + setSelectedItemId(snapshot.items[0]?.id ?? null) + setSelectedGroupKey(null) + setServerNotice( + snapshot.items.length || snapshot.categories.length + ? '' + : '服务端暂无封边资料,新增后写入服务端资料库', + ) + }) + .catch((error) => { + if (isCancelled) return + setServerError(error instanceof Error ? error.message : '封边资料库读取失败') + }) + .finally(() => { + if (!isCancelled) { + setIsServerLoading(false) + } + }) + + return () => { + isCancelled = true } - }, [categories]) + }, []) const filteredItems = useMemo(() => { const nameKeyword = normalizeText(appliedQueryName) @@ -400,7 +503,6 @@ export default function EdgeBandingLibraryPanel({ sortedFilteredItems, ]) - const [selectedGroupKey, setSelectedGroupKey] = useState(null) const activeGroup = selectedGroupKey === null && (editorMode === 'create' || editorMode === 'createCategory') @@ -552,8 +654,35 @@ export default function EdgeBandingLibraryPanel({ setPanelMessage('') } - function saveDraft() { - if (!canEditItems) return + async function commitEdgeBandingLibrary( + nextItems: EdgeBandingLibraryItem[], + nextCategories: EdgeBandingLibraryCategory[], + ) { + setIsServerSaving(true) + try { + const savedRows = await replaceIntakeReferenceLibraryItems( + EDGE_BANDING_REFERENCE_LIBRARY_TYPE, + buildEdgeBandingReferenceLibraryRows(nextItems, nextCategories), + ) + const snapshot = decodeEdgeBandingReferenceLibraryRows(savedRows) + setItems(snapshot.items) + setCategories(snapshot.categories) + window.dispatchEvent(new Event(EDGE_BANDING_LIBRARY_CHANGED_EVENT)) + setServerError('') + setServerNotice('') + return snapshot + } catch (error) { + const message = error instanceof Error ? error.message : '封边资料库保存失败' + setPanelMessage(message) + setServerError(message) + return null + } finally { + setIsServerSaving(false) + } + } + + async function saveDraft() { + if (!canEditItems || isServerSaving) return if (!draftCanSave) { setPanelMessage('请先填完整规格名称、规格、单位、单价和适配板材厚度。') @@ -569,13 +698,12 @@ export default function EdgeBandingLibraryPanel({ id: editingItemId ?? undefined, }) - setItems((current) => { - if (editingItemId) { - return current.map((item) => (item.id === editingItemId ? nextItem : item)) - } + const nextItems = editingItemId + ? items.map((item) => (item.id === editingItemId ? nextItem : item)) + : [...items, nextItem] + const snapshot = await commitEdgeBandingLibrary(nextItems, categories) + if (!snapshot) return - return [...current, nextItem] - }) setEditorMode('edit') setEditingItemId(nextItem.id) setSelectedGroupKey(getEdgeBandingGroupKey(nextItem)) @@ -590,8 +718,8 @@ export default function EdgeBandingLibraryPanel({ setPanelMessage('封边资料已保存。') } - function saveCategoryDraft() { - if (!canEditItems) return + async function saveCategoryDraft() { + if (!canEditItems || isServerSaving) return const nextCategory = categoryDraft if (!nextCategory) { @@ -610,7 +738,9 @@ export default function EdgeBandingLibraryPanel({ return } - setCategories((current) => [...current, nextCategory]) + const snapshot = await commitEdgeBandingLibrary(items, [...categories, nextCategory]) + if (!snapshot) return + setSelectedGroupKey(nextCategory.key) setEditorMode('view') setEditingItemId(null) @@ -619,8 +749,8 @@ export default function EdgeBandingLibraryPanel({ setPanelMessage('封边分类已保存。') } - function deleteSelectedItem() { - if (!canEditItems) return + async function deleteSelectedItem() { + if (!canEditItems || isServerSaving) return if (!editingItemId) return @@ -633,7 +763,12 @@ export default function EdgeBandingLibraryPanel({ if (!confirmed) return const fallbackItem = normalizedItems.find((item) => item.id !== editingItemId) - setItems((current) => current.filter((item) => item.id !== editingItemId)) + const snapshot = await commitEdgeBandingLibrary( + items.filter((item) => item.id !== editingItemId), + categories, + ) + if (!snapshot) return + setEditorMode('view') setEditingItemId(null) setEditorDraft(createEmptyEdgeBandingDraft()) @@ -642,8 +777,8 @@ export default function EdgeBandingLibraryPanel({ setPanelMessage('封边资料已删除。') } - function deleteActiveGroup() { - if (!canEditItems) return + async function deleteActiveGroup() { + if (!canEditItems || isServerSaving) return if (!activeGroup) return @@ -654,10 +789,12 @@ export default function EdgeBandingLibraryPanel({ if (!confirmed) return const fallbackItem = normalizedItems.find((item) => !targetIds.has(item.id)) - setCategories((current) => - current.filter((category) => category.key !== activeGroup.key), + const snapshot = await commitEdgeBandingLibrary( + items.filter((item) => !targetIds.has(item.id)), + categories.filter((category) => category.key !== activeGroup.key), ) - setItems((current) => current.filter((item) => !targetIds.has(item.id))) + if (!snapshot) return + setEditorMode('view') setEditingItemId(null) setEditorDraft(createEmptyEdgeBandingDraft()) @@ -710,6 +847,7 @@ export default function EdgeBandingLibraryPanel({ size="small" className={`${LIBRARY_PRIMARY_BUTTON_CLASS} h-9 whitespace-nowrap text-xs`} onClick={startCreate} + disabled={isServerSaving} > 新建封边 @@ -726,6 +864,12 @@ export default function EdgeBandingLibraryPanel({ +
+ 当前共 {normalizedItems.length} 条封边资料 + + {isServerLoading ? '正在读取服务端资料库' : serverError || serverNotice || '已接入服务端资料库'} + +
@@ -749,6 +893,7 @@ export default function EdgeBandingLibraryPanel({ size="small" className={`!min-h-8 !px-2 !py-1 text-xs ${LIBRARY_PRIMARY_BUTTON_CLASS}`} onClick={startCreateCategory} + disabled={isServerSaving} > 新建分类 @@ -759,7 +904,7 @@ export default function EdgeBandingLibraryPanel({ size="small" className="!min-h-8 !px-2 !py-1 text-xs" onClick={deleteActiveGroup} - disabled={!activeGroup} + disabled={!activeGroup || isServerSaving} > 删除分类 @@ -891,6 +1036,7 @@ export default function EdgeBandingLibraryPanel({ variant="plain" className="!min-h-8 !px-2.5 !py-1 text-xs" onClick={deleteSelectedItem} + disabled={isServerSaving} > 删除 @@ -912,10 +1058,10 @@ export default function EdgeBandingLibraryPanel({ size="small" className={`!min-h-8 !px-3 !py-1 text-xs ${LIBRARY_PRIMARY_BUTTON_CLASS}`} onClick={isCreatingCategory ? saveCategoryDraft : saveDraft} - disabled={isCreatingCategory ? !categoryCanSave : !draftCanSave} + disabled={isServerSaving || (isCreatingCategory ? !categoryCanSave : !draftCanSave)} > - 保存 + {isServerSaving ? '保存中' : '保存'}
diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 6306a2c4..05548348 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -12,14 +12,17 @@ import { createDefaultDocumentWorkspaceConfirmations, createEmptyNonIntegratedOpeningReference, getNonIntegratedDocumentWorkspaceCategoryLabel, + listNonIntegratedProducts, NON_INTEGRATED_BRAND_PREFIX_MAP, NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES, - NON_INTEGRATED_STORAGE_KEY, + replaceNonIntegratedProducts, + clearNonIntegratedProductsRuntimeCache, } from './non-integrated/nonIntegratedStorage' import { + clearProductPatentRecordsRuntimeCache, + listProductPatentRecords, mergeProductPatentRecordsWithProducts, - PRODUCT_PATENT_STORAGE_KEY, } from './productPatentStorage' const push = jest.fn() @@ -221,6 +224,26 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({ jest.mock('@/lib/open-product/platform-view', () => ({ __esModule: true, readPreviewNodes: () => [], + readPreSkuTextField: ( + preSku: { + basicInfo?: Record | null + displaySummary?: Record | null + familySelection?: Record | null + }, + key: string, + ) => { + const basicInfoKey = key === 'skuCode' ? 'formalSkuCode' : key + const basicInfoValue = preSku.basicInfo?.[basicInfoKey] + if (typeof basicInfoValue === 'string' && basicInfoValue.trim()) { + return basicInfoValue.trim() + } + const displayValue = preSku.displaySummary?.[key] + if (typeof displayValue === 'string' && displayValue.trim()) { + return displayValue.trim() + } + const familyValue = preSku.familySelection?.[key] + return typeof familyValue === 'string' ? familyValue.trim() : '' + }, })) jest.mock('@/components/platform/ModelPreviewPanel', () => ({ @@ -551,6 +574,8 @@ function createApprovedWorkflowStateBefore(targetCategory: string) { describe('IntakeClient', () => { beforeEach(() => { jest.useRealTimers() + clearNonIntegratedProductsRuntimeCache() + clearProductPatentRecordsRuntimeCache() window.localStorage.clear() window.sessionStorage.clear() jest.restoreAllMocks() @@ -661,9 +686,7 @@ describe('IntakeClient', () => { ) await waitFor(() => { - const storedRecords = JSON.parse( - window.localStorage.getItem(PRODUCT_PATENT_STORAGE_KEY) || '[]', - ) + const storedRecords = listProductPatentRecords() expect(storedRecords[0]?.patentStatus).toBe('已有专利') expect(storedRecords[0]?.saRequired).toBe('是') @@ -862,9 +885,7 @@ describe('IntakeClient', () => { expect(within(firstRow as HTMLElement).getByText('1')).toBeInTheDocument() expect(within(secondRow as HTMLElement).getByText('2')).toBeInTheDocument() - const storedRecords = JSON.parse( - window.localStorage.getItem(PRODUCT_PATENT_STORAGE_KEY) || '[]', - ) + const storedRecords = listProductPatentRecords() expect(storedRecords.map((record: { spu?: string }) => record.spu)).toEqual([ 'PATENT-ROW-1', 'PATENT-ROW-2', @@ -971,9 +992,7 @@ describe('IntakeClient', () => { expect( screen.queryByRole('button', { name: '新增专利' }), ).not.toBeInTheDocument() - expect( - JSON.parse(window.localStorage.getItem(PRODUCT_PATENT_STORAGE_KEY) || '[]'), - ).toEqual([]) + expect(listProductPatentRecords()).toEqual([]) }) it('shows patent application approval in the product development list when the form has data', async () => { @@ -993,10 +1012,7 @@ describe('IntakeClient', () => { }), ] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify(products), - ) + replaceNonIntegratedProducts(products) renderSelector({ eligibleSkuList: [], @@ -1050,10 +1066,7 @@ describe('IntakeClient', () => { }), ] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify(products), - ) + replaceNonIntegratedProducts(products) renderSelector({ eligibleSkuList: [], @@ -1316,10 +1329,7 @@ describe('IntakeClient', () => { designer: '徐霞客', }), ] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify(products), - ) + replaceNonIntegratedProducts(products) renderSelector({ eligibleSkuList: [], @@ -1370,10 +1380,7 @@ describe('IntakeClient', () => { }), }), ] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify(products), - ) + replaceNonIntegratedProducts(products) renderSelector({ eligibleSkuList: [], @@ -1617,15 +1624,12 @@ describe('IntakeClient', () => { it('opens SKU material process config as a top workspace tab', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0230-material-workspace', spu: 'SZ0230', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -1872,9 +1876,7 @@ describe('IntakeClient', () => { it('opens the series development list with the same product detail capabilities', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'series-desk-1', spu: 'SER001', @@ -1889,8 +1891,7 @@ describe('IntakeClient', () => { spu: 'DNZ0001', productDevelopmentKind: 'product', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -1975,9 +1976,7 @@ describe('IntakeClient', () => { it('keeps product and series development lists separated', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'series-desk-1', spu: 'SER001', @@ -1988,8 +1987,7 @@ describe('IntakeClient', () => { spu: 'DNZ0001', productDevelopmentKind: 'product', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -2007,15 +2005,12 @@ describe('IntakeClient', () => { const actor = userEvent.setup() const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true) - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ previewImage: 'data:image/png;base64,test-image', previewImageName: 'preview.png', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -2043,10 +2038,7 @@ describe('IntakeClient', () => { }) it('hides delete action from non-admin users', () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([createNonIntegratedProduct()]), - ) + replaceNonIntegratedProducts([createNonIntegratedProduct()]) renderSelector({ eligibleSkuList: [], @@ -2069,9 +2061,7 @@ describe('IntakeClient', () => { }) it('lets supervisors open products for full editing without delete action', () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ productPlanning: 'Planning A', openingReference: { @@ -2079,8 +2069,7 @@ describe('IntakeClient', () => { planningOwner: 'Planning A', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2104,10 +2093,7 @@ describe('IntakeClient', () => { it('hides unrelated products from non-matching designers in the responsible scope', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([createNonIntegratedProduct()]), - ) + replaceNonIntegratedProducts([createNonIntegratedProduct()]) renderSelector({ eligibleSkuList: [], @@ -2123,9 +2109,7 @@ describe('IntakeClient', () => { }) it('lets matching structure designers open products for editing', () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ designer: 'Appearance A', openingReference: { @@ -2134,8 +2118,7 @@ describe('IntakeClient', () => { structureDesigner: 'Structure A', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2152,9 +2135,7 @@ describe('IntakeClient', () => { }) it('lets product creators open products for editing before designer fields are filled', () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ createdBy: 'creator-user-id', designer: '', @@ -2164,8 +2145,7 @@ describe('IntakeClient', () => { structureDesigner: '', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2184,9 +2164,7 @@ describe('IntakeClient', () => { it('filters responsible products by the current DingTalk account name', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'caishen-created', spu: 'CS0001', @@ -2207,8 +2185,7 @@ describe('IntakeClient', () => { designer: '徐霞客', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2226,9 +2203,7 @@ describe('IntakeClient', () => { it('filters responsible products by purchase owner for purchasing users', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'purchase-owner-a', spu: 'BUY0001', @@ -2241,8 +2216,7 @@ describe('IntakeClient', () => { purchaser: 'Purchasing B', purchaseOwner: 'Purchasing B', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2261,9 +2235,7 @@ describe('IntakeClient', () => { it('filters responsible products by space designer for space visual users', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'space-owner-a', spu: 'SPACE0001', @@ -2278,8 +2250,7 @@ describe('IntakeClient', () => { visualDirector: 'Space B', designer: 'Space A', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2298,9 +2269,7 @@ describe('IntakeClient', () => { it('lets space visual department users open stock products for planning layout editing', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'stock-space-node-default', spu: 'STOCKSPACE001', @@ -2313,8 +2282,7 @@ describe('IntakeClient', () => { currentStage: '存量商品', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2341,9 +2309,7 @@ describe('IntakeClient', () => { it('filters responsible products by operation owner for operation users', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'operation-owner-a', spu: 'OPS0001', @@ -2359,8 +2325,7 @@ describe('IntakeClient', () => { operationsOwner: 'Operation B', operator: 'Operation B', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2379,9 +2344,7 @@ describe('IntakeClient', () => { it('shows pending products only when the current node belongs to the user', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ ...createApprovedWorkflowStateBefore('产品定厂'), id: 'purchase-factory-pending', @@ -2400,8 +2363,7 @@ describe('IntakeClient', () => { designer: 'Designer A', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2420,9 +2382,7 @@ describe('IntakeClient', () => { it('lets planning users open products for opening-reference editing only', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ productPlanning: 'Planning A', openingReference: { @@ -2430,8 +2390,7 @@ describe('IntakeClient', () => { planningOwner: 'Planning A', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2465,15 +2424,12 @@ describe('IntakeClient', () => { it('lets purchasing users open products for limited editing without product management actions', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ purchaser: 'Purchasing A', purchaseOwner: 'Purchasing A', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [], @@ -2507,14 +2463,11 @@ describe('IntakeClient', () => { it('keeps abandoned products view-only from the list actions', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ projectStatus: 'rejected', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -2550,9 +2503,7 @@ describe('IntakeClient', () => { const openingReferenceCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0] const projectReviewCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[2] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ projectStatus: 'rejected', documentWorkspaceConfirmations: @@ -2562,8 +2513,7 @@ describe('IntakeClient', () => { [projectReviewCategory]: '2026-04-28T06:33:00.000Z', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -2583,9 +2533,7 @@ describe('IntakeClient', () => { within(restoredRow as HTMLElement).queryByRole('button', { name: '恢复' }), ).not.toBeInTheDocument() - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? '[]', - ) + const savedProducts = listNonIntegratedProducts() expect(savedProducts[0].projectStatus).toBe('draft') expect(savedProducts[0].openingReference.currentStage).toBe('立项前') expect(savedProducts[0].documentWorkspaceApprovedAt).toEqual({}) @@ -2607,10 +2555,7 @@ describe('IntakeClient', () => { it('opens non-integrated products and BOM libraries as workspace tabs', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([createNonIntegratedProduct()]), - ) + replaceNonIntegratedProducts([createNonIntegratedProduct()]) renderSelector({ eligibleSkuList: [] }) @@ -2717,10 +2662,7 @@ describe('IntakeClient', () => { it('keeps each product workspace on the last selected right-side node when switching top tabs', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([createNonIntegratedProduct()]), - ) + replaceNonIntegratedProducts([createNonIntegratedProduct()]) renderSelector({ eligibleSkuList: [] }) @@ -2742,10 +2684,7 @@ describe('IntakeClient', () => { const actor = userEvent.setup() const product = createNonIntegratedProduct() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([product]), - ) + replaceNonIntegratedProducts([product]) const firstRender = renderSelector({ eligibleSkuList: [], @@ -2846,10 +2785,7 @@ describe('IntakeClient', () => { it('opens a separate workspace tab each time product edit is clicked', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([createNonIntegratedProduct()]), - ) + replaceNonIntegratedProducts([createNonIntegratedProduct()]) renderSelector({ eligibleSkuList: [] }) @@ -2867,9 +2803,7 @@ describe('IntakeClient', () => { const [sourceBrand, targetBrand] = Object.keys(NON_INTEGRATED_BRAND_PREFIX_MAP) const expectedSpu = `${NON_INTEGRATED_BRAND_PREFIX_MAP[targetBrand]}SZ0230` - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0230-1', spu: 'SZ0230', @@ -2878,8 +2812,7 @@ describe('IntakeClient', () => { createdAt: '2026-04-17T10:00:00.000Z', updatedAt: '2026-04-17T10:00:00.000Z', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -2945,9 +2878,7 @@ describe('IntakeClient', () => { const readyCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0] const pendingCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[1] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0230-checked', spu: 'SZ0230', @@ -2990,8 +2921,7 @@ describe('IntakeClient', () => { createdAt: '2026-04-17T10:05:00.000Z', updatedAt: '2026-04-17T10:05:00.000Z', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3035,9 +2965,7 @@ describe('IntakeClient', () => { it('hides project review from document status columns while keeping opening reference status', () => { const openingReferenceCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0232-form-data', spu: 'SZ0232', @@ -3073,8 +3001,7 @@ describe('IntakeClient', () => { [openingReferenceCategory]: '2026-04-28T06:30:00.000Z', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3113,9 +3040,7 @@ describe('IntakeClient', () => { issues: [], } - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0233-auto-review', spu: 'SZ0233', @@ -3165,8 +3090,7 @@ describe('IntakeClient', () => { 评审资料: '2026-05-09T08:00:00.000Z', }, }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3266,10 +3190,7 @@ describe('IntakeClient', () => { }, }) - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([product]), - ) + replaceNonIntegratedProducts([product]) renderSelector({ eligibleSkuList: [] }) @@ -3294,9 +3215,7 @@ describe('IntakeClient', () => { const [sourceBrand, targetBrand] = Object.keys(NON_INTEGRATED_BRAND_PREFIX_MAP) const expectedSpu = `${NON_INTEGRATED_BRAND_PREFIX_MAP[targetBrand]}SZ0230` - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0230-1', spu: 'SZ0230', @@ -3307,8 +3226,7 @@ describe('IntakeClient', () => { createdAt: '2026-04-17T10:00:00.000Z', updatedAt: '2026-04-17T10:00:00.000Z', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3327,9 +3245,7 @@ describe('IntakeClient', () => { }) expect(push).not.toHaveBeenCalled() - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? '[]', - ) + const savedProducts = listNonIntegratedProducts() const copiedProduct = savedProducts.find( (item: { brand?: string }) => item.brand === targetBrand, ) @@ -3349,9 +3265,7 @@ describe('IntakeClient', () => { NON_INTEGRATED_BRAND_PREFIX_MAP, ) - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'SZ0230-source', spu: 'SZ0230', @@ -3366,8 +3280,7 @@ describe('IntakeClient', () => { copiedFromProductId: 'SZ0230-source', copiedFromSpu: 'SZ0230', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3384,9 +3297,7 @@ describe('IntakeClient', () => { it('shows owner fields and filters non-integrated products by query button', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ id: 'DNZ0002-1', spu: 'GBGJ-DNZ0002', @@ -3413,8 +3324,7 @@ describe('IntakeClient', () => { createdAt: '2026-04-15T02:00:00.000Z', updatedAt: '2026-04-15T02:00:00.000Z', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3609,15 +3519,12 @@ describe('IntakeClient', () => { it('opens a larger preview when clicking the non-integrated product thumbnail', async () => { const actor = userEvent.setup() - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createNonIntegratedProduct({ previewImage: 'data:image/png;base64,test-image', previewImageName: 'preview.png', }), - ]), - ) + ]) renderSelector({ eligibleSkuList: [] }) @@ -3640,10 +3547,7 @@ describe('IntakeClient', () => { }), ] - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify(products), - ) + replaceNonIntegratedProducts(products) renderSelector({ eligibleSkuList: [], diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 31e6e5d1..3e0927f2 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -54,6 +54,7 @@ import Button from "@/components/ui/Button"; import { PlatformSplitLayout } from "@/components/platform/PlatformSplitLayout"; import { AccordionSection } from "@/components/platform/AccordionSection"; import { QUALITY_INSPECTION_EDITABLE_FIELD_KEYS } from "@/lib/non-integrated-quality-inspection-schema"; +import { readPreSkuTextField } from "@/lib/open-product/platform-view"; import { createDefaultDocumentWorkspaceConfirmations, createCopiedNonIntegratedProduct, @@ -119,9 +120,11 @@ import { import { getNextProductPatentFeeDueDate, listProductPatentRecords, + listProductPatentRecordsFromDatabase, mergeProductPatentRecordsWithProducts, PRODUCT_PATENT_FEE_YEARS, saveProductPatentRecords, + saveProductPatentRecordsToDatabase, type ProductPatentContinueDecision, type ProductPatentFeeStatus, type ProductPatentRecord, @@ -2240,23 +2243,6 @@ function isEditorMode( return "detail" in props && props.detail !== undefined; } -function readPreSkuTextField( - preSku: DraftGroupDetailDTO["preSkus"][number], - key: string, -) { - const displayValue = preSku.displaySummary?.[key]; - if (typeof displayValue === "string" && displayValue.trim().length > 0) { - return displayValue.trim(); - } - - const familyValue = preSku.familySelection?.[key]; - if (typeof familyValue === "string" && familyValue.trim().length > 0) { - return familyValue.trim(); - } - - return ""; -} - function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -2526,34 +2512,32 @@ function getDraftGroupSeriesNameOptions(props: IntakeClientProps) { function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[] { if (isEditorMode(props)) { - return props.detail.preSkus.map((preSku, index) => ({ - id: preSku.id, - draftGroupId: props.detail.draftGroup.id, - spuCode: - readPreSkuTextField(preSku, "spuCode") || - readPreSkuTextField(preSku, "spu") || - props.detail.draftGroup.code || - `SPU-${String(index + 1).padStart(3, "0")}`, - code: - readPreSkuTextField(preSku, "draftSkuCode") || - readPreSkuTextField(preSku, "skuSequenceCode") || - readPreSkuTextField(preSku, "skuCode") || - preSku.customName || - preSku.defaultName, - name: preSku.customName || preSku.defaultName, - seriesName: - readPreSkuTextField(preSku, "seriesName") || - readDraftGroupSeriesName(props.detail.draftGroup), - draftGroupName: props.detail.draftGroup.name, - status: mapStatus(preSku.status), - componentCodes: preSku.componentCodes ?? [], - componentDetails: readPreSkuComponentDetails(preSku), - holePoints: buildSkuHolePointsFromVariantSelection(preSku), - skuFields: readPreSkuSkuFields(preSku), - model3dPreviewNodes: preSku.previewNodes.length - ? preSku.previewNodes - : preSku.assemblyWorkspace?.nodes ?? [], - })); + return props.detail.preSkus.map((preSku, index) => { + const skuFields = readPreSkuSkuFields(preSku); + return { + id: preSku.id, + draftGroupId: props.detail.draftGroup.id, + spuCode: + readPreSkuTextField(preSku, "spuCode") || + readPreSkuTextField(preSku, "spu") || + props.detail.draftGroup.code || + `SPU-${String(index + 1).padStart(3, "0")}`, + code: skuFields.skuCode || preSku.customName || preSku.defaultName, + name: preSku.customName || preSku.defaultName, + seriesName: + readPreSkuTextField(preSku, "seriesName") || + readDraftGroupSeriesName(props.detail.draftGroup), + draftGroupName: props.detail.draftGroup.name, + status: mapStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], + componentDetails: readPreSkuComponentDetails(preSku), + holePoints: buildSkuHolePointsFromVariantSelection(preSku), + skuFields, + model3dPreviewNodes: preSku.previewNodes.length + ? preSku.previewNodes + : preSku.assemblyWorkspace?.nodes ?? [], + }; + }); } return props.eligibleSkuList ?? []; @@ -7445,11 +7429,19 @@ function ProductPatentStatusDashboard({ const [appliedQuery, setAppliedQuery] = useState(queryDraft); useEffect(() => { - setRecords((current) => { - const next = mergeProductPatentRecordsWithProducts(current, products); - saveProductPatentRecords(next); - return next; - }); + let cancelled = false; + void (async () => { + const storedRecords = await listProductPatentRecordsFromDatabase().catch( + () => listProductPatentRecords(), + ); + const next = mergeProductPatentRecordsWithProducts(storedRecords, products); + if (cancelled) return; + setRecords(next); + void saveProductPatentRecordsToDatabase(next).catch(() => undefined); + })(); + return () => { + cancelled = true; + }; }, [products]); const queryMatchedRecords = useMemo(() => { @@ -7615,13 +7607,26 @@ function ProductPatentStatusDashboard({ }; function refreshRecords() { - const nextRecords = mergeProductPatentRecordsWithProducts( - listProductPatentRecords(), - products, - ); - saveProductPatentRecords(nextRecords); - setRecords(nextRecords); - setAppliedQuery(queryDraft); + void (async () => { + const storedRecords = await listProductPatentRecordsFromDatabase().catch( + () => listProductPatentRecords(), + ); + const nextRecords = mergeProductPatentRecordsWithProducts( + storedRecords, + products, + ); + await saveProductPatentRecordsToDatabase(nextRecords); + setRecords(nextRecords); + setAppliedQuery(queryDraft); + })().catch(() => { + const nextRecords = mergeProductPatentRecordsWithProducts( + listProductPatentRecords(), + products, + ); + saveProductPatentRecords(nextRecords); + setRecords(nextRecords); + setAppliedQuery(queryDraft); + }); } const statusFilterControls = ( @@ -8336,11 +8341,19 @@ export function ProductPatentDashboard({ }, [products, user.name, user.role]); useEffect(() => { - setRecords((current) => { - const next = mergeProductPatentRecordsWithProducts(current, products); - saveProductPatentRecords(next); - return next; - }); + let cancelled = false; + void (async () => { + const storedRecords = await listProductPatentRecordsFromDatabase().catch( + () => listProductPatentRecords(), + ); + const next = mergeProductPatentRecordsWithProducts(storedRecords, products); + if (cancelled) return; + setRecords(next); + void saveProductPatentRecordsToDatabase(next).catch(() => undefined); + })(); + return () => { + cancelled = true; + }; }, [products]); useEffect(() => { @@ -8513,7 +8526,11 @@ export function ProductPatentDashboard({ }, [selectedRecordId, visiblePatentRecords]); function persistNextRecords(nextRecords: ProductPatentRecord[]) { - saveProductPatentRecords(nextRecords); + void saveProductPatentRecordsToDatabase(nextRecords).catch(() => { + saveProductPatentRecords(nextRecords); + setSaveMessage("本地已缓存,数据库保存失败"); + window.setTimeout(() => setSaveMessage(""), 2400); + }); setSaveMessage("已保存"); window.setTimeout(() => setSaveMessage(""), 1600); } diff --git a/furniture-3d-system/src/app/intake/PackagingMaterialLibraryPanel.tsx b/furniture-3d-system/src/app/intake/PackagingMaterialLibraryPanel.tsx index 5c69e7e0..e6e4fe20 100644 --- a/furniture-3d-system/src/app/intake/PackagingMaterialLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/PackagingMaterialLibraryPanel.tsx @@ -1,10 +1,7 @@ 'use client' import AlphabetLibraryPanel from '@/app/intake/AlphabetLibraryPanel' -import { - PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT, - PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY, -} from '@/lib/packaging-material-library' +import { PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT } from '@/lib/packaging-material-library' export default function PackagingMaterialLibraryPanel({ canEditItems = true, @@ -13,11 +10,11 @@ export default function PackagingMaterialLibraryPanel({ }) { return ( ) } diff --git a/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx b/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx index 2287f6c9..4b1c7059 100644 --- a/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx @@ -99,9 +99,9 @@ const SAMPLE_PACKAGE_TEMPLATE_COLUMNS = [ '判断依据', ] -const EDGE_BANDING_UNIT_PRICE = 0.2 -const BOARD_PRICE_AREA_SQUARE_METER = 2.97 -const DEFAULT_PACKAGING_PRICES = { +const REFERENCE_ESTIMATOR_EDGE_BANDING_UNIT_PRICE = 0.2 +const REFERENCE_ESTIMATOR_BOARD_PRICE_AREA_SQUARE_METER = 2.97 +const REFERENCE_ESTIMATOR_PACKAGING_PRICES = { cartonPerSquareMeter: 3.8, peBag: 0.8, foamCorner: 0.12, @@ -501,10 +501,10 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: cartonSurfaceAreaSquareMeter, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.cartonPerSquareMeter, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.cartonPerSquareMeter, amount: cartonSurfaceAreaSquareMeter * - DEFAULT_PACKAGING_PRICES.cartonPerSquareMeter, + REFERENCE_ESTIMATOR_PACKAGING_PRICES.cartonPerSquareMeter, remark: `按拆装板件叠放预估;箱高约${formatNumber(cartonHeightMm, 0)}mm`, }, ESTIMATE_VARIANCE_RATES.packaging, @@ -525,8 +525,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.peBag, - amount: DEFAULT_PACKAGING_PRICES.peBag, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.peBag, + amount: REFERENCE_ESTIMATOR_PACKAGING_PRICES.peBag, remark: '按拆装外箱尺寸预估', }, ESTIMATE_VARIANCE_RATES.packaging, @@ -547,8 +547,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.foamCorner, - amount: 8 * DEFAULT_PACKAGING_PRICES.foamCorner, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.foamCorner, + amount: 8 * REFERENCE_ESTIMATOR_PACKAGING_PRICES.foamCorner, remark: '柜体八角预估', }, ESTIMATE_VARIANCE_RATES.packaging, @@ -569,8 +569,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: foamEdgeMeters, - unitPrice: DEFAULT_PACKAGING_PRICES.foamEdgePerMeter, - amount: foamEdgeMeters * DEFAULT_PACKAGING_PRICES.foamEdgePerMeter, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.foamEdgePerMeter, + amount: foamEdgeMeters * REFERENCE_ESTIMATOR_PACKAGING_PRICES.foamEdgePerMeter, remark: '按拆装外箱十二边预估', }, ESTIMATE_VARIANCE_RATES.packaging, @@ -591,8 +591,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.sealingTape, - amount: DEFAULT_PACKAGING_PRICES.sealingTape, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.sealingTape, + amount: REFERENCE_ESTIMATOR_PACKAGING_PRICES.sealingTape, remark: '按单件包装预估', }, ESTIMATE_VARIANCE_RATES.packaging, @@ -613,8 +613,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.cartonLabel, - amount: DEFAULT_PACKAGING_PRICES.cartonLabel, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.cartonLabel, + amount: REFERENCE_ESTIMATOR_PACKAGING_PRICES.cartonLabel, remark: '按单箱一张预估', }, ESTIMATE_VARIANCE_RATES.packaging, @@ -635,8 +635,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.instructionSheet, - amount: DEFAULT_PACKAGING_PRICES.instructionSheet, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.instructionSheet, + amount: REFERENCE_ESTIMATOR_PACKAGING_PRICES.instructionSheet, remark: '按单件一份预估', }, ESTIMATE_VARIANCE_RATES.accessory, @@ -657,8 +657,8 @@ function buildPackagingAndAccessoryRows( coefficient: 1, areaSquareMeter: null, edgeMeters: null, - unitPrice: DEFAULT_PACKAGING_PRICES.certificate, - amount: DEFAULT_PACKAGING_PRICES.certificate, + unitPrice: REFERENCE_ESTIMATOR_PACKAGING_PRICES.certificate, + amount: REFERENCE_ESTIMATOR_PACKAGING_PRICES.certificate, remark: '按单件一张预估', }, ESTIMATE_VARIANCE_RATES.accessory, @@ -951,7 +951,7 @@ export function ReferenceImageCostEstimator({ const pricePerSquareMeter = boardSheetPrice === null ? null - : boardSheetPrice / BOARD_PRICE_AREA_SQUARE_METER + : boardSheetPrice / REFERENCE_ESTIMATOR_BOARD_PRICE_AREA_SQUARE_METER const manualMaterialName = parseManualBoardMaterialName(boardThicknessText) const mainMaterialName = manualMaterialName || @@ -966,7 +966,7 @@ export function ReferenceImageCostEstimator({ const plywoodPricePerSquareMeter = plywoodSheetPrice === null ? null - : plywoodSheetPrice / BOARD_PRICE_AREA_SQUARE_METER + : plywoodSheetPrice / REFERENCE_ESTIMATOR_BOARD_PRICE_AREA_SQUARE_METER const boardThicknessMm = parseBoardThicknessInput(boardThicknessText) ?? selectedMaterial?.thicknessMm ?? @@ -1045,11 +1045,11 @@ export function ReferenceImageCostEstimator({ coefficient: templateCoefficient, areaSquareMeter: null, edgeMeters: sumTemplateEdgeMeters(boardTemplateRows), - unitPrice: EDGE_BANDING_UNIT_PRICE, + unitPrice: REFERENCE_ESTIMATOR_EDGE_BANDING_UNIT_PRICE, amount: sumTemplateEdgeMeters(boardTemplateRows) * templateCoefficient * - EDGE_BANDING_UNIT_PRICE, + REFERENCE_ESTIMATOR_EDGE_BANDING_UNIT_PRICE, remark: '按模板五金/封边口径预估', }, ESTIMATE_VARIANCE_RATES.edgeBanding, diff --git a/furniture-3d-system/src/app/intake/SpuRuleLibraryWorkspace.tsx b/furniture-3d-system/src/app/intake/SpuRuleLibraryWorkspace.tsx index cc867043..148c139a 100644 --- a/furniture-3d-system/src/app/intake/SpuRuleLibraryWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/SpuRuleLibraryWorkspace.tsx @@ -11,7 +11,10 @@ import { Upload, X, } from "lucide-react"; -import { SPU_RULE_LIBRARY_STORAGE_KEY } from "./spuRuleLibraryLogic"; +import { + listSpuRuleLibraryRulesFromServer, + replaceSpuRuleLibraryRulesOnServer, +} from "./spuRuleLibraryLogic"; import { SPU_RULE_LIBRARY_SEED_RULES } from "./spuRuleLibrarySeed"; type SpuRule = { @@ -56,8 +59,6 @@ type ImportSummary = { missingIndexCount: number; }; -const STORAGE_KEY = SPU_RULE_LIBRARY_STORAGE_KEY; - const emptyDraft: EditableSpuRule = { topCategory: "", topCategoryCode: "", @@ -159,29 +160,12 @@ function getSeedRules() { ).filter((rule): rule is SpuRule => Boolean(rule)); } -function loadRules() { - if (typeof window === "undefined") return getSeedRules(); - try { - const stored = window.localStorage.getItem(STORAGE_KEY); - if (!stored) return getSeedRules(); - const parsed = JSON.parse(stored); - if (!Array.isArray(parsed)) return getSeedRules(); - const rules = parsed - .map((rule, index) => normalizeRuleRecord(rule, index)) - .filter((rule): rule is SpuRule => Boolean(rule)); - return rules.length > 0 ? rules : getSeedRules(); - } catch { - return getSeedRules(); - } -} +function normalizeWorkspaceRules(rules: unknown[]) { + const normalizedRules = rules + .map((rule, index) => normalizeRuleRecord(rule, index)) + .filter((rule): rule is SpuRule => Boolean(rule)); -function saveRules(rules: SpuRule[]) { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); - } catch { - // 本地维护缓存不可用时,当前页面仍可继续操作。 - } + return normalizedRules.length > 0 ? normalizedRules : getSeedRules(); } function createRuleFromImportedRow( @@ -378,18 +362,69 @@ function RuleEditor({ } export function SpuRuleLibraryWorkspace() { - const [rules, setRules] = useState(() => loadRules()); + const [rules, setRules] = useState(() => getSeedRules()); const [topCategoryQuery, setTopCategoryQuery] = useState(""); const [secondCategoryQuery, setSecondCategoryQuery] = useState(""); const [productCategoryQuery, setProductCategoryQuery] = useState(""); const [summary, setSummary] = useState(null); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(null); + const [isServerLoading, setIsServerLoading] = useState(true); + const [isServerSaving, setIsServerSaving] = useState(false); + const [serverError, setServerError] = useState(""); + const [serverNotice, setServerNotice] = useState(""); const fileInputRef = useRef(null); useEffect(() => { - saveRules(rules); - }, [rules]); + let isCancelled = false; + setIsServerLoading(true); + setServerError(""); + setServerNotice(""); + + listSpuRuleLibraryRulesFromServer() + .then((snapshot) => { + if (isCancelled) return; + if (snapshot.hasServerRows) { + setRules(normalizeWorkspaceRules(snapshot.rules)); + setServerNotice(""); + } else { + setRules(getSeedRules()); + setServerNotice("服务端暂无 SPU 规则,当前显示内置种子规则,保存后写入服务端"); + } + }) + .catch((error) => { + if (isCancelled) return; + setServerError(error instanceof Error ? error.message : "SPU 规则库读取失败"); + }) + .finally(() => { + if (!isCancelled) { + setIsServerLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, []); + + async function commitRules(nextRules: SpuRule[]) { + setIsServerSaving(true); + setServerError(""); + setServerNotice(""); + + try { + const snapshot = await replaceSpuRuleLibraryRulesOnServer(nextRules); + const savedRules = normalizeWorkspaceRules(snapshot.rules); + setRules(savedRules); + return savedRules; + } catch (error) { + const message = error instanceof Error ? error.message : "SPU 规则库保存失败"; + setServerError(message); + return null; + } finally { + setIsServerSaving(false); + } + } const filteredRules = useMemo(() => { const topQuery = topCategoryQuery.trim().toLowerCase(); @@ -408,6 +443,8 @@ export function SpuRuleLibraryWorkspace() { }, [productCategoryQuery, rules, secondCategoryQuery, topCategoryQuery]); async function handleImport(event: ChangeEvent) { + if (isServerSaving) return; + const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; @@ -426,15 +463,17 @@ export function SpuRuleLibraryWorkspace() { .filter((rule): rule is SpuRule => Boolean(rule)); const dedupedRules = dedupeRules(rawRules); - setRules(dedupedRules); + const savedRules = await commitRules(dedupedRules); + if (!savedRules) return; + setSummary({ fileName: file.name, rawCount: rawRules.length, - uniqueCount: dedupedRules.length, + uniqueCount: savedRules.length, duplicateRows: rawRules.length - dedupedRules.length, - conflictCount: dedupedRules.filter((rule) => rule.conflict).length, - missingPrefixCount: dedupedRules.filter((rule) => !isSpuPrefix(rule.categoryCode)).length, - missingIndexCount: dedupedRules.filter((rule) => rule.spuIndex <= 0).length, + conflictCount: savedRules.filter((rule) => rule.conflict).length, + missingPrefixCount: savedRules.filter((rule) => !isSpuPrefix(rule.categoryCode)).length, + missingIndexCount: savedRules.filter((rule) => rule.spuIndex <= 0).length, }); } @@ -470,6 +509,8 @@ export function SpuRuleLibraryWorkspace() { } function openEditor(rule: SpuRule) { + if (isServerSaving) return; + setEditingId(rule.id); setDraft({ topCategory: rule.topCategory, @@ -485,7 +526,7 @@ export function SpuRuleLibraryWorkspace() { }); } - function saveDraft() { + async function saveDraft() { if (!draft) return; const nextRule: SpuRule = { id: editingId ?? `manual-${Date.now()}`, @@ -507,13 +548,14 @@ export function SpuRuleLibraryWorkspace() { if (!nextRule.productCategory) return; - setRules((current) => - dedupeRules( - editingId - ? current.map((rule) => (rule.id === editingId ? nextRule : rule)) - : [...current, nextRule], - ), + const nextRules = dedupeRules( + editingId + ? rules.map((rule) => (rule.id === editingId ? nextRule : rule)) + : [...rules, nextRule], ); + const savedRules = await commitRules(nextRules); + if (!savedRules) return; + setEditingId(null); setDraft(null); } @@ -539,6 +581,7 @@ export function SpuRuleLibraryWorkspace() { +
+ 当前共 {items.length} 条钢架规格 + + {isServerLoading + ? '正在读取服务端资料库' + : serverError || serverNotice || '已接入服务端资料库'} + +
@@ -480,6 +622,7 @@ export default function SteelFrameLibraryPanel({ variant="plain" className="!h-7 !min-h-0 !px-2 !py-0 whitespace-nowrap text-[11px] [&>span]:gap-1" onClick={handleDeleteDraft} + disabled={isServerSaving} > 删除 @@ -490,6 +633,7 @@ export default function SteelFrameLibraryPanel({ variant="plain" className="!h-7 !min-h-0 !px-2 !py-0 whitespace-nowrap text-[11px] [&>span]:gap-1" onClick={handleCopyDraft} + disabled={isServerSaving} > 复制 @@ -501,10 +645,10 @@ export default function SteelFrameLibraryPanel({ size="small" className={`${LIBRARY_PRIMARY_BUTTON_CLASS} !h-7 !min-h-0 !px-2 !py-0 whitespace-nowrap text-[11px] [&>span]:gap-1`} onClick={handleSaveDraft} - disabled={!draftCanSave} + disabled={isServerSaving || !draftCanSave} > - 保存 + {isServerSaving ? '保存中' : '保存'}
diff --git a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts index 7e263dee..40b897d2 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts @@ -50,6 +50,23 @@ function buildDraftGroupDetail( brand: '顾家', color: '胡桃', }, + basicInfo: { + model: '', + formalSkuCode: 'GBGJ-DNZ000101', + barcode: '', + size: '', + color: '', + material: '', + specName: '', + versionNo: '', + setCount: '', + attributeCode: '', + drawingId: '', + installMethod: '', + manualUpdatedAt: null, + manualUpdatedBy: null, + manualUpdatedByName: null, + }, candidateSnapshot: null, assemblyWorkspace: null, assemblyResultStatus: null, @@ -95,6 +112,23 @@ function buildDraftGroupDetail( brand: '基本盘', color: '暖白', }, + basicInfo: { + model: '', + formalSkuCode: 'DNZ000102', + barcode: '', + size: '', + color: '', + material: '', + specName: '', + versionNo: '', + setCount: '', + attributeCode: '', + drawingId: '', + installMethod: '', + manualUpdatedAt: null, + manualUpdatedBy: null, + manualUpdatedByName: null, + }, candidateSnapshot: null, assemblyWorkspace: null, assemblyResultStatus: null, @@ -132,14 +166,14 @@ describe('adaptDraftGroupToIntegratedDefaults', () => { seriesName: '集成书桌系列', baseSpu: 'DNZ0001', defaultBrand: '基本盘', - adaptableBrands: ['顾家', '基本盘'], + adaptableBrands: ['代理品牌-顾家', '基本盘'], skuItems: [ { id: 'pre-sku-1', skuSequence: 1, skuName: '款式一', baseSkuCode: 'DNZ000101', - brand: '顾家', + brand: '代理品牌-顾家', }, { id: 'pre-sku-2', @@ -163,6 +197,10 @@ describe('adaptDraftGroupToIntegratedDefaults', () => { spuCode: 'GBSFY-DNZ2001', skuCode: 'GBSFY-DNZ200101', }, + basicInfo: { + ...buildDraftGroupDetail().preSkus[0]!.basicInfo!, + formalSkuCode: 'GBSFY-DNZ200101', + }, }, { ...buildDraftGroupDetail().preSkus[1]!, @@ -172,30 +210,9 @@ describe('adaptDraftGroupToIntegratedDefaults', () => { spuCode: 'GBGJ-DNZ2001', skuCode: 'GBGJ-DNZ200102', }, - }, - ], - }) - - const result = adaptDraftGroupToIntegratedDefaults(detail) - - expect(result.defaultBrand).toBe('索菲亚') - expect(result.adaptableBrands).toEqual(['索菲亚', '顾家']) - expect(result.baseSpu).toBe('DNZ2001') - expect(result.skuItems.map((item) => item.baseSkuCode)).toEqual([ - 'DNZ200101', - 'DNZ200102', - ]) - }) - - it('can detect brand from summary text when spuCode has no prefix', () => { - const detail = buildDraftGroupDetail({ - preSkus: [ - { - ...buildDraftGroupDetail().preSkus[0]!, - displaySummary: { - spuCode: 'DNZ3001', - skuCode: 'DNZ300101', - summaryText: '品牌:朴美;适配渠道:朴美', + basicInfo: { + ...buildDraftGroupDetail().preSkus[1]!.basicInfo!, + formalSkuCode: 'GBGJ-DNZ200102', }, }, ], @@ -203,8 +220,37 @@ describe('adaptDraftGroupToIntegratedDefaults', () => { const result = adaptDraftGroupToIntegratedDefaults(detail) - expect(result.defaultBrand).toBe('朴美') - expect(result.adaptableBrands).toEqual(['朴美']) + expect(result.defaultBrand).toBe('代理品牌-索菲亚') + expect(result.adaptableBrands).toEqual(['代理品牌-索菲亚', '代理品牌-顾家']) + expect(result.baseSpu).toBe('DNZ2001') + expect(result.skuItems.map((item) => item.baseSkuCode)).toEqual([ + 'DNZ200101', + 'DNZ200102', + ]) + }) + + it('detects brand from structured SKU code instead of legacy summary text', () => { + const detail = buildDraftGroupDetail({ + preSkus: [ + { + ...buildDraftGroupDetail().preSkus[0]!, + displaySummary: { + spuCode: 'DNZ3001', + skuCode: 'DNZ300101', + summaryText: '品牌:代理品牌-顾家;适配渠道:代理品牌-顾家', + }, + basicInfo: { + ...buildDraftGroupDetail().preSkus[0]!.basicInfo!, + formalSkuCode: 'GBPM-DNZ300101', + }, + }, + ], + }) + + const result = adaptDraftGroupToIntegratedDefaults(detail) + + expect(result.defaultBrand).toBe('自有品牌-朴美') + expect(result.adaptableBrands).toEqual(['自有品牌-朴美']) expect(result.baseSpu).toBe('DNZ3001') expect(result.skuItems).toEqual([ { @@ -212,8 +258,74 @@ describe('adaptDraftGroupToIntegratedDefaults', () => { skuSequence: 1, skuName: '款式一', baseSkuCode: 'DNZ300101', - brand: '朴美', + brand: '自有品牌-朴美', }, ]) }) + + it('uses draft group series fields instead of legacy familySelection seriesName', () => { + const detail = buildDraftGroupDetail({ + draftGroup: { + ...buildDraftGroupDetail().draftGroup, + assetSeriesName: '结构化系列名', + name: '草稿组名称', + }, + preSkus: [ + { + ...buildDraftGroupDetail().preSkus[0]!, + familySelection: { + seriesName: '旧快照系列名', + }, + }, + ], + }) + + const result = adaptDraftGroupToIntegratedDefaults(detail) + + expect(result.seriesName).toBe('结构化系列名') + }) + + it('does not use legacy displaySummary.spuCode as integrated SPU source', () => { + const detail = buildDraftGroupDetail({ + draftGroup: { + ...buildDraftGroupDetail().draftGroup, + code: 'DG-STRUCTURED', + }, + preSkus: [ + { + ...buildDraftGroupDetail().preSkus[0]!, + basicInfo: null, + displaySummary: { + spuCode: 'GBGJ-DNZ4001', + skuCode: 'GBGJ-DNZ400101', + brand: '顾家', + }, + }, + ], + }) + + const result = adaptDraftGroupToIntegratedDefaults(detail) + + expect(result.baseSpu).toBe('DG-STRUCTURED') + }) + + it('does not use legacy displaySummary.skuCode as integrated SKU code source', () => { + const detail = buildDraftGroupDetail({ + preSkus: [ + { + ...buildDraftGroupDetail().preSkus[0]!, + basicInfo: null, + displaySummary: { + spuCode: 'GBGJ-DNZ4001', + skuCode: 'GBGJ-DNZ400101', + brand: '顾家', + }, + }, + ], + }) + + const result = adaptDraftGroupToIntegratedDefaults(detail) + + expect(result.skuItems[0]?.baseSkuCode).toBe('') + }) }) diff --git a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts index 82046946..eba5291a 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts @@ -3,6 +3,7 @@ import { NON_INTEGRATED_BRAND_PREFIX_MAP, stripNonIntegratedBrandPrefix, } from '../non-integrated/nonIntegratedStorage' +import { readPreSkuSkuFields } from '../seriesComponentDetails' export type IntegratedSeriesSkuDefault = { id: string @@ -20,57 +21,47 @@ export type IntegratedSeriesDefaults = { skuItems: IntegratedSeriesSkuDefault[] } -const KNOWN_BRANDS = Object.keys(NON_INTEGRATED_BRAND_PREFIX_MAP) - -function readDisplayText(displaySummary: Record | null | undefined) { - if (!displaySummary) return '' - - return Object.values(displaySummary) - .filter((value): value is string => typeof value === 'string') - .join(' ') -} - -function detectBrand(preSku: DraftGroupDetailDTO['preSkus'][number]) { - const rawSpuCode = preSku.displaySummary?.spuCode - const normalizedSpuCode = typeof rawSpuCode === 'string' ? rawSpuCode.trim().toUpperCase() : '' +function detectBrandFromCode(value: string) { + const normalizedCode = value.trim().toUpperCase() + if (!normalizedCode) return null for (const [brand, prefix] of Object.entries(NON_INTEGRATED_BRAND_PREFIX_MAP)) { - if (prefix && normalizedSpuCode.startsWith(prefix)) { + if (prefix && normalizedCode.startsWith(prefix)) { return brand } } - const explicitBrand = preSku.displaySummary?.brand - if (typeof explicitBrand === 'string' && KNOWN_BRANDS.includes(explicitBrand.trim())) { - return explicitBrand.trim() - } + return null +} - const summaryText = readDisplayText(preSku.displaySummary) - return KNOWN_BRANDS.find((brand) => summaryText.includes(brand)) ?? '基本盘' +function detectBrand(preSku: DraftGroupDetailDTO['preSkus'][number]) { + const skuFields = readPreSkuSkuFields(preSku) + return detectBrandFromCode(skuFields.skuCode) ?? '基本盘' } function readSeriesName(detail: DraftGroupDetailDTO) { - const familySeriesName = detail.preSkus.find((preSku) => { - const value = preSku.familySelection?.seriesName - return typeof value === 'string' && value.trim().length > 0 - })?.familySelection?.seriesName - - if (typeof familySeriesName === 'string' && familySeriesName.trim().length > 0) { - return familySeriesName.trim() - } - return detail.draftGroup.assetSeriesName?.trim() || detail.draftGroup.name.trim() } -function pickBaseSpu(detail: DraftGroupDetailDTO) { - for (const preSku of detail.preSkus) { - const rawSpuCode = preSku.displaySummary?.spuCode - if (typeof rawSpuCode === 'string' && rawSpuCode.trim().length > 0) { - return stripNonIntegratedBrandPrefix(rawSpuCode) - } +function inferBaseSpuFromSkuCode(value: string) { + const normalizedCode = stripNonIntegratedBrandPrefix(value) + if (!normalizedCode) return '' + + if (/^[A-Z]+-SPJ\d{5}$/.test(normalizedCode)) { + return '' } - return '' + const legacySkuMatch = normalizedCode.match(/^(.+?)\d{2}[A-Z]?$/) + return legacySkuMatch?.[1] ?? '' +} + +function pickBaseSpu(detail: DraftGroupDetailDTO, skuCodes: string[]) { + for (const skuCode of skuCodes) { + const inferredSpu = inferBaseSpuFromSkuCode(skuCode) + if (inferredSpu) return inferredSpu + } + + return detail.draftGroup.code?.trim() ?? '' } function pickDefaultBrand(adaptableBrands: string[]) { @@ -85,18 +76,22 @@ export function adaptDraftGroupToIntegratedDefaults( detail: DraftGroupDetailDTO, ): IntegratedSeriesDefaults { const adaptableBrands: string[] = [] + const skuCodes: string[] = [] const skuItems = detail.preSkus.map((preSku) => { + const structuredSkuCode = readPreSkuSkuFields(preSku).skuCode + if (structuredSkuCode) { + skuCodes.push(structuredSkuCode) + } + const brand = detectBrand(preSku) if (!adaptableBrands.includes(brand)) { adaptableBrands.push(brand) } - const rawSkuCode = preSku.displaySummary?.skuCode - const baseSkuCode = - typeof rawSkuCode === 'string' && rawSkuCode.trim().length > 0 - ? stripNonIntegratedBrandPrefix(rawSkuCode) - : '' + const baseSkuCode = structuredSkuCode + ? stripNonIntegratedBrandPrefix(structuredSkuCode) + : '' return { id: preSku.id, @@ -109,7 +104,7 @@ export function adaptDraftGroupToIntegratedDefaults( return { seriesName: readSeriesName(detail), - baseSpu: pickBaseSpu(detail), + baseSpu: pickBaseSpu(detail, skuCodes), defaultBrand: pickDefaultBrand(adaptableBrands), adaptableBrands, skuItems, diff --git a/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts b/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts index f6b74288..5f1d6b16 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts @@ -52,6 +52,23 @@ function buildDraftGroupDetail(id: string, spuCode: string): DraftGroupDetailDTO skuCode: `${spuCode}01`, brand: '基本盘', }, + basicInfo: { + model: '', + formalSkuCode: `${spuCode}01`, + barcode: '', + size: '', + color: '', + material: '', + specName: '', + versionNo: '', + setCount: '', + attributeCode: '', + drawingId: '', + installMethod: '', + manualUpdatedAt: null, + manualUpdatedBy: null, + manualUpdatedByName: null, + }, candidateSnapshot: null, assemblyWorkspace: null, assemblyResultStatus: null, diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 431439a8..8530d01d 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -23,20 +23,26 @@ import { createDefaultDocumentWorkspaceConfirmations, createEmptyNonIntegratedOpeningReference, createEmptyNonIntegratedPatentApplication, + clearNonIntegratedProductsRuntimeCache, getNonIntegratedProductById, + listNonIntegratedProducts, NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, - NON_INTEGRATED_STORAGE_KEY, + replaceNonIntegratedProducts, saveNonIntegratedProduct, type NonIntegratedSkuItem, type NonIntegratedProductRecord, } from "./nonIntegratedStorage"; import { - PRODUCT_PATENT_STORAGE_KEY, + clearProductPatentRecordsRuntimeCache, + listProductPatentRecords, type ProductPatentRecord, } from "../productPatentStorage"; import type { MaterialDTO } from "@/server/services"; -import { SPU_RULE_LIBRARY_STORAGE_KEY } from "../spuRuleLibraryLogic"; +import { + clearSpuRuleLibraryRuntimeCache, + writeSpuRuleLibraryRules, +} from "../spuRuleLibraryLogic"; import { parseZipFile } from "@/lib/zip-parser"; import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip"; import { fetchSubstrates } from "@/lib/open-product/platform-api"; @@ -443,8 +449,30 @@ describe("NonIntegratedDetailClient", () => { beforeEach(() => { push.mockReset(); modelPreviewPanelMock.mockReset(); + clearSpuRuleLibraryRuntimeCache(); + clearNonIntegratedProductsRuntimeCache(); + clearProductPatentRecordsRuntimeCache(); window.localStorage.clear(); window.sessionStorage.clear(); + Object.defineProperty(global, "fetch", { + configurable: true, + writable: true, + value: jest.fn(async () => ({ + ok: true, + json: async () => ({ + success: true, + data: { + items: [], + names: [], + overrides: {}, + }, + }), + blob: async () => new Blob([""], { type: "application/octet-stream" }), + headers: { + get: () => null, + }, + })), + }); anchorClickSpy = jest .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => {}); @@ -531,18 +559,15 @@ describe("NonIntegratedDetailClient", () => { it("creates a local product record and jumps to detail page after submit", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - SPU_RULE_LIBRARY_STORAGE_KEY, - JSON.stringify([ - { - id: "test-desk-rule", - productCategory: "书桌", - categoryCode: "SZ", - spuIndex: 240, - enabled: true, - }, - ]), - ); + writeSpuRuleLibraryRules([ + { + id: "test-desk-rule", + productCategory: "书桌", + categoryCode: "SZ", + spuIndex: 240, + enabled: true, + }, + ]); const { container } = render( { await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]", - ) as NonIntegratedProductRecord[]; + const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[]; expect(savedProducts).toHaveLength(1); - expect(savedProducts[0]?.spu).toBe("GBGJ-SZ0241"); - expect(savedProducts[0]?.productCode).toBe("GBGJ-SZ0241"); + expect(savedProducts[0]?.spu).toBe("KK202605001"); + expect(savedProducts[0]?.formalSpuAssigned).not.toBe(true); + expect(savedProducts[0]?.productCode).toBe(""); expect(savedProducts[0]?.projectStatus).toBe("draft"); expect(savedProducts[0]?.createdBy).toBe("Creator"); expect(savedProducts[0]?.updatedBy).toBe("Creator"); @@ -663,9 +687,7 @@ describe("NonIntegratedDetailClient", () => { await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]", - ) as NonIntegratedProductRecord[]; + const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[]; expect(savedProducts[0]?.productDevelopmentKind).toBe("series"); expect(savedProducts[0]?.spu).toBe("GBGJ-SZ0241"); @@ -746,20 +768,35 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("系列名称")).toBeInTheDocument(); }); - it("creates a normal product SPU from the SPU rule library instead of the opening code", async () => { + it("asks the backend save transaction to assign a normal product SPU instead of using the opening code", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - SPU_RULE_LIBRARY_STORAGE_KEY, - JSON.stringify([ - { - id: "test-product-rule", - productCategory: "测试", - categoryCode: "CS", - spuIndex: 1, - enabled: true, - }, - ]), + (globalThis as NonIntegratedRemoteAssetStorageTestGlobal) + .__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true; + mockedSaveSharedNonIntegratedProduct.mockImplementationOnce( + async (product) => ({ + ...product, + id: "remote-CS0002", + spu: "CS0002", + productCode: "CS0002", + formalSpuAssigned: true, + skuItems: [ + { + ...product.skuItems[0], + skuCode: "CS000201", + barcode: "CS000201V01", + }, + ], + }), ); + writeSpuRuleLibraryRules([ + { + id: "test-product-rule", + productCategory: "测试", + categoryCode: "CS", + spuIndex: 1, + enabled: true, + }, + ]); const { container } = render( { await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]", - ) as NonIntegratedProductRecord[]; - - expect(savedProducts[0]?.spu).toBe("CS0002"); - expect(savedProducts[0]?.productCode).toBe("CS0002"); - expect(savedProducts[0]?.openingReference.temporaryCode).toBe("测试"); - expect(savedProducts[0]?.skuItems[0]?.skuCode).toBe("CS000201"); + expect(mockedSaveSharedNonIntegratedProduct).toHaveBeenCalledWith( + expect.objectContaining({ + spu: "测试", + openingReference: expect.objectContaining({ + temporaryCode: "测试", + }), + }), + expect.objectContaining({ + formalSpuAssignmentProductCategory: "测试", + }), + ); + expect(push).toHaveBeenCalledWith("/intake/non-integrated/remote-CS0002"); }); it("shows series component details from the barcode magnifier without leaking them into attribute columns", async () => { @@ -884,18 +925,15 @@ describe("NonIntegratedDetailClient", () => { (globalThis as NonIntegratedRemoteAssetStorageTestGlobal) .__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true; const actor = userEvent.setup(); - window.localStorage.setItem( - SPU_RULE_LIBRARY_STORAGE_KEY, - JSON.stringify([ - { - id: "remote-upload-rule", - productCategory: "书桌", - categoryCode: "SZ", - spuIndex: 240, - enabled: true, - }, - ]), - ); + writeSpuRuleLibraryRules([ + { + id: "remote-upload-rule", + productCategory: "书桌", + categoryCode: "SZ", + spuIndex: 240, + enabled: true, + }, + ]); const { container } = render(); await actor.type(screen.getByLabelText("名称"), "远程开品"); @@ -1307,18 +1345,15 @@ describe("NonIntegratedDetailClient", () => { it("keeps the opening code blank when saving a new demand without manual opening code", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - SPU_RULE_LIBRARY_STORAGE_KEY, - JSON.stringify([ - { - id: "blank-opening-code-rule", - productCategory: "书桌", - categoryCode: "SZ", - spuIndex: 240, - enabled: true, - }, - ]), - ); + writeSpuRuleLibraryRules([ + { + id: "blank-opening-code-rule", + productCategory: "书桌", + categoryCode: "SZ", + spuIndex: 240, + enabled: true, + }, + ]); const { container } = render(); await actor.type(screen.getByLabelText("名称"), "仅填写名称的开品"); @@ -1334,19 +1369,18 @@ describe("NonIntegratedDetailClient", () => { await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]", - ) as NonIntegratedProductRecord[]; + const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[]; expect(savedProducts).toHaveLength(1); expect(savedProducts[0]?.openingReference.productName).toBe( "仅填写名称的开品", ); expect(savedProducts[0]?.openingReference.temporaryCode).toBe(""); - expect(savedProducts[0]?.spu).toBe("SZ0241"); + expect(savedProducts[0]?.spu).toMatch(/^NON-INTEGRATED-\d+$/); + expect(savedProducts[0]?.formalSpuAssigned).not.toBe(true); }); - it("shows an estimated SPU, assigns it on SKU creation, and locks categories", async () => { + it("shows an estimated SPU for SKU editing without occupying a formal number first", async () => { const actor = userEvent.setup(); saveNonIntegratedProduct( @@ -1386,28 +1420,14 @@ describe("NonIntegratedDetailClient", () => { await actor.click(screen.getByTestId("non-integrated-add-sku")); expect(await screen.findByDisplayValue("CJ026701")).toBeInTheDocument(); - expect(screen.getByDisplayValue("CJ026702")).toBeInTheDocument(); - expect(screen.getByLabelText("SPU")).toHaveValue("CJ0267"); - expect(screen.getByLabelText("品类")).toHaveAttribute("readonly"); - expect(screen.getByLabelText("类目")).toHaveAttribute("readonly"); + expect(await screen.findByDisplayValue("CJ026702")).toBeInTheDocument(); + expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:CJ0267"); + expect(screen.getByLabelText("品类")).not.toHaveAttribute("readonly"); + expect(screen.getByLabelText("类目")).not.toHaveAttribute("readonly"); - await waitFor(() => { - const savedProduct = getNonIntegratedProductById("temp-code-product"); - expect(savedProduct?.spu).toBe("CJ0267"); - expect(savedProduct?.formalSpuAssigned).toBe(true); - expect(savedProduct?.skuItems[0]?.skuCode).toBe("CJ026701"); - }); - const storedRules = JSON.parse( - window.localStorage.getItem(SPU_RULE_LIBRARY_STORAGE_KEY) ?? "[]", - ) as Array<{ productCategory?: string; spuIndex?: number }>; - expect( - storedRules.find((rule) => rule.productCategory === "茶几")?.spuIndex, - ).toBe(267); - - await actor.click(screen.getByTestId("document-workspace-trigger-开品参考")); - - expect(screen.getByLabelText("品类")).toHaveAttribute("readonly"); - expect(screen.getByLabelText("类目")).toHaveAttribute("readonly"); + const savedProduct = getNonIntegratedProductById("temp-code-product"); + expect(savedProduct?.spu).toBe("KP-001"); + expect(savedProduct?.formalSpuAssigned).not.toBe(true); }); it("applies the current brand prefix when opening an existing formal SPU", () => { @@ -1645,9 +1665,7 @@ describe("NonIntegratedDetailClient", () => { }); it("shows the document sidebar in the updated workflow order", () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-sidebar-order", uploadFiles: [ @@ -1671,8 +1689,7 @@ describe("NonIntegratedDetailClient", () => { 检测报告: true, }, }), - ]), - ); + ]); render( , @@ -1741,9 +1758,7 @@ describe("NonIntegratedDetailClient", () => { }); it("keeps opening-reference link view scoped to the linked product reference", () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-link-entry", openingReference: { @@ -1777,8 +1792,7 @@ describe("NonIntegratedDetailClient", () => { temporaryCode: "KP-OTHER", }, }), - ]), - ); + ]); render( { }); it("shows all workflow nodes by default without the project review node", () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-draft-project", projectStatus: "draft", @@ -1836,8 +1848,7 @@ describe("NonIntegratedDetailClient", () => { reason: "补齐书房价格带", }, }), - ]), - ); + ]); render( , @@ -1879,9 +1890,7 @@ describe("NonIntegratedDetailClient", () => { }); it("keeps the project review node hidden for returned products", () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-revision-project", projectStatus: "revision", @@ -1910,8 +1919,7 @@ describe("NonIntegratedDetailClient", () => { decidedAt: "2026-04-28T07:59:02.000Z", }, }), - ]), - ); + ]); render( { expect(savedProduct?.patentApplication.shouldApply).toBe("申请"); expect(savedProduct?.patentApplication.designer).toBe("徐霞客"); - const patentRecords = JSON.parse( - window.localStorage.getItem(PRODUCT_PATENT_STORAGE_KEY) ?? "[]", - ) as ProductPatentRecord[]; + const patentRecords = listProductPatentRecords() as ProductPatentRecord[]; expect(patentRecords).toHaveLength(1); expect(patentRecords[0]).toMatchObject({ productId: "SZ0230-patent-application-sync", @@ -2618,9 +2624,7 @@ describe("NonIntegratedDetailClient", () => { expect(savedProduct?.patentApplication.shouldApply).toBe("不申请"); expect(savedProduct?.patentApplication.patentStatus).toBe("无专利"); - const patentRecords = JSON.parse( - window.localStorage.getItem(PRODUCT_PATENT_STORAGE_KEY) ?? "[]", - ) as ProductPatentRecord[]; + const patentRecords = listProductPatentRecords() as ProductPatentRecord[]; expect(patentRecords).toHaveLength(0); }); }); @@ -3358,9 +3362,7 @@ describe("NonIntegratedDetailClient", () => { it("removes SKU content confirmation actions and keeps SKU fields editable before factory confirmation", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-sku-confirmation-removed", skuItems: [ @@ -3377,8 +3379,7 @@ describe("NonIntegratedDetailClient", () => { skuBatchChangeTypes: { 1: "新品" }, skuBatchFlowNos: { 1: "DD20260519001" }, }), - ]), - ); + ]); render( { it("renders first quality rows by SKU batch", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-factory-quality", skuItems: [ @@ -5685,8 +5684,7 @@ describe("NonIntegratedDetailClient", () => { }, ], }), - ]), - ); + ]); render( { it("renders installation style tabs from SKU models", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "SZ0230-installation-styles", skuItems: [ @@ -5739,8 +5735,7 @@ describe("NonIntegratedDetailClient", () => { }), ], }), - ]), - ); + ]); render( { "/api/intake/non-integrated/assets/document-previews/cache/page-1.jpg", ], }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-pdf-preview", uploadFiles: [ @@ -6823,8 +6816,7 @@ describe("NonIntegratedDetailClient", () => { }, ], }), - ]), - ); + ]); render( , @@ -6855,9 +6847,7 @@ describe("NonIntegratedDetailClient", () => { it("uses the page header return action to go back from document workspace to detail view", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-document-return", uploadFiles: [ @@ -6870,8 +6860,7 @@ describe("NonIntegratedDetailClient", () => { }, ], }), - ]), - ); + ]); render( { it("keeps the sidebar on basic info instead of falling back to opening reference", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-basic-info-sidebar", uploadFiles: [ @@ -6914,8 +6901,7 @@ describe("NonIntegratedDetailClient", () => { }, ], }), - ]), - ); + ]); render( { it("marks copied document categories as pending until the current category is confirmed", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-pending-confirmation", copiedFromProductId: "source-product", @@ -6959,8 +6943,7 @@ describe("NonIntegratedDetailClient", () => { 产品实拍图: false, }, }), - ]), - ); + ]); render( { ); await waitFor(() => { - const savedProducts = JSON.parse( - window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]", - ); + const savedProducts = listNonIntegratedProducts(); expect( savedProducts[0].documentWorkspaceConfirmations["产品实拍图"], ).toBe(true); @@ -6997,9 +6978,7 @@ describe("NonIntegratedDetailClient", () => { const actor = userEvent.setup(); const approvedAt = "2026-04-28T06:30:00.000Z"; - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-sku-independent-confirmation", skuItems: [createSkuItem()], @@ -7022,8 +7001,7 @@ describe("NonIntegratedDetailClient", () => { 产品模型: approvedAt, }, }), - ]), - ); + ]); render( { }); it("counts review data as zero until the first round has a filled issue", () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-empty-review-count", spu: "SZ0230", }), - ]), - ); + ]); const emptyRender = render( { ).toBeInTheDocument(); emptyRender.unmount(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-filled-review-count", spu: "SZ0230", @@ -7116,8 +7089,7 @@ describe("NonIntegratedDetailClient", () => { ], }, }), - ]), - ); + ]); render( { const actor = userEvent.setup(); const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-review-round-confirm", spu: "SZ0230", @@ -7166,8 +7136,7 @@ describe("NonIntegratedDetailClient", () => { ], }, }), - ]), - ); + ]); try { render( @@ -7207,9 +7176,7 @@ describe("NonIntegratedDetailClient", () => { it("locks copied products to a single brand while keeping adaptable brands editable", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "GBGJ-DNZ0001-copied-brand", spu: "GBGJ-DNZ0001", @@ -7218,8 +7185,7 @@ describe("NonIntegratedDetailClient", () => { copiedFromProductId: "DNZ0001-source", copiedFromSpu: "DNZ0001", }), - ]), - ); + ]); render( { it("keeps the brand field locked after an original product is created", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "SZ0230-brand-locked", spu: "SZ0230", @@ -7281,8 +7245,7 @@ describe("NonIntegratedDetailClient", () => { copiedFromProductId: undefined, copiedFromSpu: undefined, }), - ]), - ); + ]); render( , @@ -7314,16 +7277,13 @@ describe("NonIntegratedDetailClient", () => { it("opens a larger preview in edit mode when clicking the product image", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-1", createdAt: "2026-04-15T10:00:00.000Z", updatedAt: "2026-04-15T10:00:00.000Z", }), - ]), - ); + ]); render(); @@ -7336,14 +7296,11 @@ describe("NonIntegratedDetailClient", () => { it("matches product photo nine-view uploads by file name and keeps detail images separate", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-product-photo-batch", }), - ]), - ); + ]); render( { it("uploads directly when clicking an empty nine-view slot", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-product-photo-slot-upload", }), - ]), - ); + ]); render( { it("uploads grouped product photos from the empty detail panel", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-product-photo-empty-detail-upload", }), - ]), - ); + ]); render( { model3dDataUrl: "", }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-product-photo-sku-styles", skuItems: [createSkuItem({ model: "SZ0230K01" })], styleDetails: ["K01", "K02", "K03"].map(createStyleDetail), }), - ]), - ); + ]); render( { }); it("keeps 3D preview gizmo selection and undo behavior aligned with asset center", async () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-3d-gizmo-undo", skuItems: [ @@ -7620,8 +7566,7 @@ describe("NonIntegratedDetailClient", () => { }), ], }), - ]), - ); + ]); render( { }); it("passes SKU hole points into 3D preview and snaps moved nodes to nearby holes", async () => { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-3d-hole-snap", skuItems: [ @@ -7743,8 +7686,7 @@ describe("NonIntegratedDetailClient", () => { }), ], }), - ]), - ); + ]); render( { roughness: 0.35, }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-3d-material-assignment", skuItems: [ @@ -7844,8 +7784,7 @@ describe("NonIntegratedDetailClient", () => { }), ], }), - ]), - ); + ]); render( { color: "#111111", }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-3d-multi-node-material", skuItems: [ @@ -7984,8 +7921,7 @@ describe("NonIntegratedDetailClient", () => { }), ], }), - ]), - ); + ]); render( { color: "#8b5a2b", }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-3d-uv-settings", skuItems: [ @@ -8140,8 +8074,7 @@ describe("NonIntegratedDetailClient", () => { }), ], }), - ]), - ); + ]); render( { }), ], }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([product]), - ); + replaceNonIntegratedProducts([product]); const deniedRender = render( { packagingModel3dDataUrl: "", }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-packaging-sku-styles", skuItems: [ @@ -8320,8 +8248,7 @@ describe("NonIntegratedDetailClient", () => { ], styleDetails: ["K01", "K02", "K03"].map(createStyleDetail), }), - ]), - ); + ]); render( { color: "#111111", }); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-packaging-3d-material", skuItems: [ @@ -8397,8 +8322,7 @@ describe("NonIntegratedDetailClient", () => { }, ], }), - ]), - ); + ]); render( { it("keeps the nine-view tab selectable when only detail photos exist", async () => { const actor = userEvent.setup(); - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify([ + replaceNonIntegratedProducts([ createProductRecord({ id: "DNZ0001-nine-view-empty", spu: "SZ0230", @@ -8591,8 +8513,7 @@ describe("NonIntegratedDetailClient", () => { }, ], }), - ]), - ); + ]); render( { - const loadSteelFrameOptions = () => { - setSteelFrameOptions(buildNonIntegratedBomSteelFrameOptions()); + let isCancelled = false; + + const loadSteelFrameOptions = async () => { + try { + const rows = await listIntakeReferenceLibraryItems( + STEEL_FRAME_REFERENCE_LIBRARY_TYPE, + ); + if (isCancelled) return; + setSteelFrameOptions( + buildNonIntegratedBomSteelFrameOptions( + decodeSteelFrameReferenceLibraryRows(rows), + ), + ); + } catch (error) { + if (!isCancelled) { + console.warn("钢架资料库读取失败,BOM 钢架匹配暂不可用。", error); + setSteelFrameOptions([]); + } + } }; loadSteelFrameOptions(); @@ -11491,17 +11520,41 @@ export default function NonIntegratedDetailClient({ if (typeof window === "undefined") return; window.addEventListener("focus", loadSteelFrameOptions); - window.addEventListener("storage", loadSteelFrameOptions); + window.addEventListener( + STEEL_FRAME_LIBRARY_CHANGED_EVENT, + loadSteelFrameOptions, + ); return () => { + isCancelled = true; window.removeEventListener("focus", loadSteelFrameOptions); - window.removeEventListener("storage", loadSteelFrameOptions); + window.removeEventListener( + STEEL_FRAME_LIBRARY_CHANGED_EVENT, + loadSteelFrameOptions, + ); }; }, []); useEffect(() => { - const loadEdgeBandingOptions = () => { - setEdgeBandingOptions(buildNonIntegratedBomEdgeBandingOptions()); + let isCancelled = false; + + const loadEdgeBandingOptions = async () => { + try { + const rows = await listIntakeReferenceLibraryItems( + EDGE_BANDING_REFERENCE_LIBRARY_TYPE, + ); + if (isCancelled) return; + setEdgeBandingOptions( + buildNonIntegratedBomEdgeBandingOptions( + decodeEdgeBandingReferenceLibraryItems(rows), + ), + ); + } catch (error) { + if (!isCancelled) { + console.warn("封边资料库读取失败,BOM 封边匹配暂不可用。", error); + setEdgeBandingOptions([]); + } + } }; loadEdgeBandingOptions(); @@ -11509,15 +11562,14 @@ export default function NonIntegratedDetailClient({ if (typeof window === "undefined") return; window.addEventListener("focus", loadEdgeBandingOptions); - window.addEventListener("storage", loadEdgeBandingOptions); window.addEventListener( EDGE_BANDING_LIBRARY_CHANGED_EVENT, loadEdgeBandingOptions, ); return () => { + isCancelled = true; window.removeEventListener("focus", loadEdgeBandingOptions); - window.removeEventListener("storage", loadEdgeBandingOptions); window.removeEventListener( EDGE_BANDING_LIBRARY_CHANGED_EVENT, loadEdgeBandingOptions, @@ -11526,8 +11578,25 @@ export default function NonIntegratedDetailClient({ }, []); useEffect(() => { - const loadAccessoryOptions = () => { - setAccessoryOptions(buildNonIntegratedBomAccessoryOptions()); + let isCancelled = false; + + const loadAccessoryOptions = async () => { + try { + const rows = await listIntakeReferenceLibraryItems( + ACCESSORY_REFERENCE_LIBRARY_TYPE, + ); + if (isCancelled) return; + setAccessoryOptions( + buildNonIntegratedBomAccessoryOptions( + decodeAccessoryReferenceLibraryRows(rows), + ), + ); + } catch (error) { + if (!isCancelled) { + console.warn("配件资料库读取失败,BOM 配件匹配暂不可用。", error); + setAccessoryOptions([]); + } + } }; loadAccessoryOptions(); @@ -11535,15 +11604,14 @@ export default function NonIntegratedDetailClient({ if (typeof window === "undefined") return; window.addEventListener("focus", loadAccessoryOptions); - window.addEventListener("storage", loadAccessoryOptions); window.addEventListener( ACCESSORY_LIBRARY_CHANGED_EVENT, loadAccessoryOptions, ); return () => { + isCancelled = true; window.removeEventListener("focus", loadAccessoryOptions); - window.removeEventListener("storage", loadAccessoryOptions); window.removeEventListener( ACCESSORY_LIBRARY_CHANGED_EVENT, loadAccessoryOptions, @@ -11552,10 +11620,25 @@ export default function NonIntegratedDetailClient({ }, []); useEffect(() => { - const loadPackagingMaterialOptions = () => { - setPackagingMaterialOptions( - buildNonIntegratedBomPackagingMaterialOptions(), - ); + let isCancelled = false; + + const loadPackagingMaterialOptions = async () => { + try { + const rows = await listIntakeReferenceLibraryItems( + PACKAGING_MATERIAL_REFERENCE_LIBRARY_TYPE, + ); + if (isCancelled) return; + setPackagingMaterialOptions( + buildNonIntegratedBomPackagingMaterialOptions( + decodePackagingMaterialReferenceLibraryRows(rows), + ), + ); + } catch (error) { + if (!isCancelled) { + console.warn("包装材料库读取失败,BOM 包材匹配暂不可用。", error); + setPackagingMaterialOptions([]); + } + } }; loadPackagingMaterialOptions(); @@ -11563,15 +11646,14 @@ export default function NonIntegratedDetailClient({ if (typeof window === "undefined") return; window.addEventListener("focus", loadPackagingMaterialOptions); - window.addEventListener("storage", loadPackagingMaterialOptions); window.addEventListener( PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT, loadPackagingMaterialOptions, ); return () => { + isCancelled = true; window.removeEventListener("focus", loadPackagingMaterialOptions); - window.removeEventListener("storage", loadPackagingMaterialOptions); window.removeEventListener( PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT, loadPackagingMaterialOptions, @@ -12330,6 +12412,22 @@ export default function NonIntegratedDetailClient({ }; }, []); + useEffect(() => { + let isMounted = true; + syncSpuRuleLibraryRulesFromServer() + .then((snapshot) => { + if (!isMounted || !snapshot.hasServerRows) return; + setSpuEstimateRefreshToken((current) => current + 1); + }) + .catch(() => { + // 服务端规则库暂不可用时,继续使用当前兼容缓存或内置规则,避免阻断开品录入。 + }); + + return () => { + isMounted = false; + }; + }, []); + const isSkuContentReadOnly = isReadOnly; const activeSkuBatchNo = activeSkuBatch === "all" ? null : activeSkuBatch; const formalSpuValue = formValues.spu.trim(); @@ -12573,10 +12671,10 @@ export default function NonIntegratedDetailClient({ buildProductLimitRows( derivedSkuItems, productLimitRows, - formValues.spu, + skuCodePreviewSpu, sharedProductImage, ), - [derivedSkuItems, formValues.spu, productLimitRows, sharedProductImage], + [derivedSkuItems, productLimitRows, sharedProductImage, skuCodePreviewSpu], ); const productSampleMeasurementRows = useMemo( () => @@ -15356,6 +15454,11 @@ export default function NonIntegratedDetailClient({ processTemplate: STOCK_PRODUCT_LABEL, } : formValuesRef.current; + const formalSpuAssignmentProductCategory = + !isStockProductRecord && !nextFormValuesForPayload.spu.trim() + ? nextFormValuesForPayload.productCategory.trim() || + nextOpeningReference.productCategory.trim() + : ""; const nextProduct = await normalizeProductAssetsForStorage( buildProductPayload( productId, @@ -15397,8 +15500,17 @@ export default function NonIntegratedDetailClient({ ? await saveSharedNonIntegratedProduct(nextProduct, { workspaceCategory: overrides?.workspaceCategory ?? activePermissionWorkspaceCategory, + formalSpuAssignmentProductCategory, }) : saveNonIntegratedProduct(nextProduct); + if (!formValuesRef.current.spu.trim() && savedProduct.spu.trim()) { + setFormValuesAndRef((current) => ({ + ...current, + spu: savedProduct.spu.trim(), + productCode: current.productCode || savedProduct.spu.trim(), + })); + setSpuEstimateRefreshToken((current) => current + 1); + } if ( nextDocumentWorkspaceConfirmations[ PATENT_APPLICATION_WORKSPACE_CATEGORY @@ -17121,7 +17233,7 @@ export default function NonIntegratedDetailClient({ ); } - function assignFormalSpuForSku() { + function ensureFormalSpuReadyForSkuEdit() { const currentSpu = formValuesRef.current.spu.trim(); if (currentSpu) return currentSpu; @@ -17138,44 +17250,16 @@ export default function NonIntegratedDetailClient({ return ""; } - const assignment = reserveSpuAssignmentForProductCategory({ - productCategory, - products: listNonIntegratedProducts(), - currentProductId: productId, - }); - - if (!assignment) { + if (!estimatedFormalSpu) { setSaveMessage("当前类目还没有配置SPU规则,请先维护SPU规则库。"); setSaveMessageType("error"); return ""; } - const nextBrand = - normalizeNonIntegratedBrand(formValuesRef.current.brand) || - normalizeNonIntegratedBrand(openingReferenceRef.current.brand) || - NON_INTEGRATED_DEFAULT_BRAND; - const formalSpu = applyBrandPrefixToNonIntegratedCode( - assignment.spu, - nextBrand, - ); - - setFormValuesAndRef((current) => ({ - ...current, - spu: formalSpu, - productCode: current.productCode || formalSpu, - salesCategory: current.salesCategory || salesCategory, - productCategory: current.productCategory || productCategory, - })); - setOpeningReferenceAndRef((current) => ({ - ...current, - salesCategory: current.salesCategory || salesCategory, - productCategory: current.productCategory || productCategory, - })); - setSpuEstimateRefreshToken((current) => current + 1); - setSaveMessage(`SPU已生效:${formalSpu}`); + setSaveMessage(`正式SPU将在保存时生效:${estimatedFormalSpu}`); setSaveMessageType("success"); - return formalSpu; + return estimatedFormalSpu; } function updateField(key: keyof FormValues, value: string) { @@ -17314,13 +17398,13 @@ export default function NonIntegratedDetailClient({ scheduleAutoSave(); } - function updateSkuItem( + async function updateSkuItem( id: string, key: keyof NonIntegratedSkuItem, value: string | number, ) { if (isReadOnly) return; - if (!assignFormalSpuForSku()) return; + if (!(await ensureFormalSpuReadyForSkuEdit())) return; setSkuItems((current) => { const targetItem = current.find((item) => item.id === id); @@ -17384,7 +17468,7 @@ export default function NonIntegratedDetailClient({ function handleBarcodeScanDetected(value: string) { if (!barcodeScannerSkuId) return; - updateSkuItem(barcodeScannerSkuId, "barcode", value.trim().toUpperCase()); + void updateSkuItem(barcodeScannerSkuId, "barcode", value.trim().toUpperCase()); setBarcodeScannerSkuId(null); } @@ -17405,7 +17489,7 @@ export default function NonIntegratedDetailClient({ const nextDerivedItem = deriveSkuItem( nextItem, index, - formValues.spu, + skuCodePreviewSpu, formValues, ); @@ -17419,9 +17503,9 @@ export default function NonIntegratedDetailClient({ }; } - function updateSkuSupplierName(id: string, value: string) { + async function updateSkuSupplierName(id: string, value: string) { if (isReadOnly) return; - if (!assignFormalSpuForSku()) return; + if (!(await ensureFormalSpuReadyForSkuEdit())) return; setSkuItems((current) => current.map((item, index) => { @@ -17435,9 +17519,9 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } - function confirmSkuSupplierName(id: string) { + async function confirmSkuSupplierName(id: string) { if (isReadOnly) return; - if (!assignFormalSpuForSku()) return; + if (!(await ensureFormalSpuReadyForSkuEdit())) return; setSkuItems((current) => current.map((item, index) => { @@ -17452,9 +17536,9 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } - function updateSkuExecuteStandard(id: string, value: string) { + async function updateSkuExecuteStandard(id: string, value: string) { if (isReadOnly) return; - if (!assignFormalSpuForSku()) return; + if (!(await ensureFormalSpuReadyForSkuEdit())) return; setSkuItems((current) => current.map((item) => { @@ -17468,9 +17552,9 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } - function confirmSkuExecuteStandard(id: string) { + async function confirmSkuExecuteStandard(id: string) { if (isReadOnly) return; - if (!assignFormalSpuForSku()) return; + if (!(await ensureFormalSpuReadyForSkuEdit())) return; setSkuItems((current) => current.map((item) => { @@ -19234,7 +19318,7 @@ export default function NonIntegratedDetailClient({ return; } - const derivedItem = deriveSkuItem(item, index, formValues.spu, formValues); + const derivedItem = deriveSkuItem(item, index, skuCodePreviewSpu, formValues); setSkuImagePreview({ src: item.image, alt: derivedItem.skuCode || item.model || "SKU图片", @@ -21310,7 +21394,7 @@ export default function NonIntegratedDetailClient({ } } - function handleAddSku(targetBatchNo = skuBatchCount) { + async function handleAddSku(targetBatchNo = skuBatchCount) { if (isSkuContentReadOnly) return; if (isSkuBatchFactoryConfirmed(targetBatchNo)) { setSaveMessage( @@ -21329,7 +21413,7 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("error"); return; } - if (!assignFormalSpuForSku()) return; + if (!(await ensureFormalSpuReadyForSkuEdit())) return; setSkuItems((current) => { const lastItem = current[current.length - 1]; @@ -21367,7 +21451,7 @@ export default function NonIntegratedDetailClient({ return; } - const formalSpu = assignFormalSpuForSku(); + const formalSpu = await ensureFormalSpuReadyForSkuEdit(); if (!formalSpu) return; const nextSkuItems = buildSeriesSkuItemsFromDraftSkus( @@ -21786,7 +21870,7 @@ export default function NonIntegratedDetailClient({ if (rows.length === 0) { throw new Error("上传资料中没有可导入的 SKU 行"); } - if (!assignFormalSpuForSku()) { + if (!(await ensureFormalSpuReadyForSkuEdit())) { throw new Error("请先生成正式SPU后再导入SKU资料"); } @@ -22615,6 +22699,7 @@ export default function NonIntegratedDetailClient({ ...formValues.adaptableBrands, ]).filter((brand) => BRAND_OPTIONS.includes(brand)); let createFormalSpu = isStockProductCreate ? stockSpu : ""; + let formalSpuAssignmentProductCategory = ""; if (!isStockProductCreate) { const productCategory = @@ -22626,19 +22711,7 @@ export default function NonIntegratedDetailClient({ if (!salesCategory || !productCategory) { throw new Error("请先填写品类和类目,系统会按SPU规则库生成正式SPU。"); } - const assignment = reserveSpuAssignmentForProductCategory({ - productCategory, - products: listNonIntegratedProducts(), - }); - - if (!assignment) { - throw new Error("当前类目还没有配置SPU规则,请先维护SPU规则库。"); - } - - createFormalSpu = applyBrandPrefixToNonIntegratedCode( - assignment.spu, - createBrand, - ); + formalSpuAssignmentProductCategory = productCategory; } const nextOpeningReference = normalizeNonIntegratedOpeningReference({ @@ -22716,8 +22789,17 @@ export default function NonIntegratedDetailClient({ { attachToExistingProduct: false }, ); const savedProduct = shouldUseRemoteAssetStorage() - ? await saveSharedNonIntegratedProduct(normalizedProduct) + ? await saveSharedNonIntegratedProduct(normalizedProduct, { + formalSpuAssignmentProductCategory, + }) : saveNonIntegratedProduct(normalizedProduct); + if (!isStockProductCreate) { + void syncSpuRuleLibraryRulesFromServer() + .then(() => setSpuEstimateRefreshToken((current) => current + 1)) + .catch(() => { + // 正式 SPU 由后端保存事务分配;规则库缓存刷新失败不影响本次创建。 + }); + } const nextProductUrl = `/intake/non-integrated/${encodeURIComponent( savedProduct.id, )}`; @@ -29119,17 +29201,26 @@ function renderFactoryQualityWorkspace() { onChange={() => undefined} /> ) : ( - undefined} - /> + <> + undefined} + /> + undefined} + /> + )} toProductRecord(item)) - .filter((item): item is NonIntegratedProductRecord => item !== null) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); - } catch { - return []; - } + return nonIntegratedRuntimeProducts + .map((item) => toProductRecord(item)) + .filter((item): item is NonIntegratedProductRecord => item !== null) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); } export function getNonIntegratedProductById(id: string) { @@ -2330,24 +2326,11 @@ export function buildNonIntegratedPackagingInnerItemId() { } function writeNonIntegratedProducts(products: NonIntegratedProductRecord[]) { - if (!canUseStorage()) return; + if (!canUseRuntimeCompatibilityStore()) return; - try { - window.localStorage.setItem( - NON_INTEGRATED_STORAGE_KEY, - JSON.stringify(products), - ); - } catch (error) { - if ( - error instanceof DOMException && - (error.name === "QuotaExceededError" || - error.name === "NS_ERROR_DOM_QUOTA_REACHED") - ) { - throw new Error(NON_INTEGRATED_STORAGE_LIMIT_MESSAGE); - } - - throw error; - } + nonIntegratedRuntimeProducts = products + .map((item) => toProductRecord(item)) + .filter((item): item is NonIntegratedProductRecord => item !== null); } export function saveNonIntegratedProduct( @@ -2355,7 +2338,7 @@ export function saveNonIntegratedProduct( updatedAt?: string; }, ): NonIntegratedProductRecord { - if (!canUseStorage()) { + if (!canUseRuntimeCompatibilityStore()) { const openingReference = normalizeNonIntegratedOpeningReference( product.openingReference, ); @@ -2622,14 +2605,18 @@ export function createCopiedNonIntegratedProduct( export function replaceNonIntegratedProducts( products: NonIntegratedProductRecord[], ) { - if (!canUseStorage()) return; + if (!canUseRuntimeCompatibilityStore()) return; writeNonIntegratedProducts(products); emitProductsUpdated(); } +export function clearNonIntegratedProductsRuntimeCache() { + nonIntegratedRuntimeProducts = []; +} + export function deleteNonIntegratedProduct(id: string) { - if (!canUseStorage()) return; + if (!canUseRuntimeCompatibilityStore()) return; const nextList = listNonIntegratedProducts().filter((item) => item.id !== id); writeNonIntegratedProducts(nextList); diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 8f48e35e..12a463af 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -7,6 +7,7 @@ import { intakeNodePermissionSettingsService } from '@/server/services/intake-no import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' import { materialService } from '@/server/services/material.service' import { warehouseService } from '@/server/services/warehouse.service' +import { readPreSkuTextField } from '@/lib/open-product/platform-view' import IntakeClient from './IntakeClient' import { readPreSkuComponentDetails, readPreSkuSkuFields } from './seriesComponentDetails' import { @@ -15,26 +16,6 @@ import { type NonIntegratedSkuHolePoint, } from './non-integrated/nonIntegratedStorage' -function readPreSkuTextField( - preSku: { - displaySummary?: Record | null - familySelection?: Record | null - }, - key: string, -) { - const displayValue = preSku.displaySummary?.[key] - if (typeof displayValue === 'string' && displayValue.trim().length > 0) { - return displayValue.trim() - } - - const familyValue = preSku.familySelection?.[key] - if (typeof familyValue === 'string' && familyValue.trim().length > 0) { - return familyValue.trim() - } - - return '' -} - function mapWorkbenchStatus(status: string) { if (status === 'FORMALIZED') return '已完成' if (status === 'READY_FOR_INTAKE') return '待执行' @@ -272,35 +253,36 @@ export default async function IntakePage({ ) const eligibleSkuList = eligibleDraftGroupDetails.flatMap((detail) => - detail.preSkus.map((preSku, index) => ({ - id: preSku.id, - draftGroupId: detail.draftGroup.id, - spuCode: - readPreSkuTextField(preSku, 'spuCode') || - readPreSkuTextField(preSku, 'spu') || - detail.draftGroup.code || - `SPU-${String(index + 1).padStart(3, '0')}`, - code: - readPreSkuTextField(preSku, 'draftSkuCode') || - readPreSkuTextField(preSku, 'skuSequenceCode') || - readPreSkuTextField(preSku, 'skuCode') || - preSku.customName || - preSku.defaultName, - name: preSku.customName || preSku.defaultName, - seriesName: - readPreSkuTextField(preSku, 'seriesName') || - readDraftGroupSeriesNameForIntake(detail.draftGroup) || - detail.draftGroup.name, - draftGroupName: detail.draftGroup.name, - status: mapWorkbenchStatus(preSku.status), - componentCodes: preSku.componentCodes ?? [], - componentDetails: readPreSkuComponentDetails(preSku), - holePoints: buildSkuHolePointsFromVariantSelection(preSku), - skuFields: readPreSkuSkuFields(preSku), - model3dPreviewNodes: preSku.previewNodes.length - ? preSku.previewNodes - : preSku.assemblyWorkspace?.nodes ?? [], - })), + detail.preSkus.map((preSku, index) => { + const skuFields = readPreSkuSkuFields(preSku) + return { + id: preSku.id, + draftGroupId: detail.draftGroup.id, + spuCode: + readPreSkuTextField(preSku, 'spuCode') || + readPreSkuTextField(preSku, 'spu') || + detail.draftGroup.code || + `SPU-${String(index + 1).padStart(3, '0')}`, + code: + skuFields.skuCode || + preSku.customName || + preSku.defaultName, + name: preSku.customName || preSku.defaultName, + seriesName: + readPreSkuTextField(preSku, 'seriesName') || + detail.draftGroup.assetSeriesName || + detail.draftGroup.name, + draftGroupName: detail.draftGroup.name, + status: mapWorkbenchStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], + componentDetails: readPreSkuComponentDetails(preSku), + holePoints: buildSkuHolePointsFromVariantSelection(preSku), + skuFields, + model3dPreviewNodes: preSku.previewNodes.length + ? preSku.previewNodes + : preSku.assemblyWorkspace?.nodes ?? [], + } + }), ) const sourceWorkspace = diff --git a/furniture-3d-system/src/app/intake/productPatentStorage.ts b/furniture-3d-system/src/app/intake/productPatentStorage.ts index f8b5f47b..4344312a 100644 --- a/furniture-3d-system/src/app/intake/productPatentStorage.ts +++ b/furniture-3d-system/src/app/intake/productPatentStorage.ts @@ -8,8 +8,6 @@ import { type NonIntegratedProductRecord, } from "./non-integrated/nonIntegratedStorage"; -export const PRODUCT_PATENT_STORAGE_KEY = "intake.productPatentRecords"; - export const PRODUCT_PATENT_FEE_YEARS = [ "第1年", "第2年", @@ -80,6 +78,8 @@ export type ProductPatentRecord = { updatedAt: string; }; +let productPatentRuntimeRecords: ProductPatentRecord[] = []; + function isPatentStatus(value: unknown): value is ProductPatentStatus { return ( value === "待确认" || @@ -496,37 +496,143 @@ export function normalizeProductPatentRecord( }); } -export function listProductPatentRecords(): ProductPatentRecord[] { - if (typeof window === "undefined") return []; +function getProductPatentRecordIdentity(record: ProductPatentRecord) { + return record.patentNo || record.applicationNo || record.patentName.trim(); +} - try { - const parsed = JSON.parse( - window.localStorage.getItem(PRODUCT_PATENT_STORAGE_KEY) || "[]", - ); - if (!Array.isArray(parsed)) return []; - return parsed - .map((item) => normalizeProductPatentRecord(item)) - .filter((item): item is ProductPatentRecord => Boolean(item)); - } catch { - return []; - } +function normalizeProductPatentIdentity(record: ProductPatentRecord): ProductPatentRecord { + const patentGroupKey = record.patentGroupKey.trim().toUpperCase(); + const normalizedId = /^patent-\d+-[a-z0-9]+$/i.test(record.id.trim()) && patentGroupKey + ? `product-patent-group-${patentGroupKey}` + : record.id.trim(); + + return { + ...record, + id: normalizedId, + patentGroupKey, + productId: record.productId.trim(), + linkedProductIds: Array.from( + new Set(record.linkedProductIds.map((item) => item.trim()).filter(Boolean)), + ), + linkedSpus: Array.from( + new Set( + record.linkedSpus + .map((item) => item.trim().toUpperCase()) + .filter(Boolean), + ), + ), + applicationNo: record.applicationNo.trim().toUpperCase(), + patentNo: record.patentNo.trim().toUpperCase(), + spu: record.spu.trim().toUpperCase(), + }; +} + +function normalizeProductPatentRecordsForSave(records: ProductPatentRecord[]) { + const seenIds = new Set(); + const seenGroupKeys = new Set(); + + return records.map((record, index) => { + const baseRecord = normalizeProductPatentRecord(record); + if (!baseRecord) { + throw new Error(`第 ${index + 1} 条专利台账记录缺少稳定 id,已阻止保存`); + } + const normalized = normalizeProductPatentIdentity(baseRecord); + if (!normalized.productId) { + throw new Error(`第 ${index + 1} 条专利台账记录缺少来源产品 id,已阻止保存`); + } + if (!normalized.patentGroupKey) { + throw new Error(`第 ${index + 1} 条专利台账记录缺少专利分组键,已阻止保存`); + } + if (!getProductPatentRecordIdentity(normalized)) { + throw new Error( + `第 ${index + 1} 条专利台账记录缺少专利名称、申请号或专利号,已阻止保存`, + ); + } + if (seenIds.has(normalized.id)) { + throw new Error(`专利台账记录 id 重复:${normalized.id}`); + } + if (seenGroupKeys.has(normalized.patentGroupKey)) { + throw new Error(`专利台账分组重复:${normalized.patentGroupKey}`); + } + seenIds.add(normalized.id); + seenGroupKeys.add(normalized.patentGroupKey); + return normalized; + }); +} + +export function listProductPatentRecords(): ProductPatentRecord[] { + return productPatentRuntimeRecords + .map((item) => normalizeProductPatentRecord(item)) + .filter((item): item is ProductPatentRecord => Boolean(item)); } export function saveProductPatentRecords(records: ProductPatentRecord[]) { - if (typeof window === "undefined") return records; + const normalizedRecords = normalizeProductPatentRecordsForSave(records); - const normalizedRecords = records - .map((record) => normalizeProductPatentRecord(record)) - .filter((record): record is ProductPatentRecord => Boolean(record)); - - window.localStorage.setItem( - PRODUCT_PATENT_STORAGE_KEY, - JSON.stringify(normalizedRecords), - ); + productPatentRuntimeRecords = normalizedRecords; return normalizedRecords; } +export function clearProductPatentRecordsRuntimeCache() { + productPatentRuntimeRecords = []; +} + +export async function listProductPatentRecordsFromDatabase() { + if (typeof window === "undefined") return [] as ProductPatentRecord[]; + if (process.env.NODE_ENV === "test") return listProductPatentRecords(); + + const response = await fetch("/api/intake/product-patents", { + method: "GET", + headers: { Accept: "application/json" }, + }); + const payload = (await response.json().catch(() => null)) as { + success?: boolean; + data?: { records?: unknown[] }; + error?: { message?: string }; + } | null; + + if (!response.ok || !payload?.success || !Array.isArray(payload.data?.records)) { + throw new Error(payload?.error?.message || "专利台账读取失败"); + } + + const records = payload.data.records + .map((item) => normalizeProductPatentRecord(item)) + .filter((item): item is ProductPatentRecord => Boolean(item)); + saveProductPatentRecords(records); + return records; +} + +export async function saveProductPatentRecordsToDatabase(records: ProductPatentRecord[]) { + const normalizedRecords = normalizeProductPatentRecordsForSave(records); + if (typeof window === "undefined") return normalizedRecords; + if (process.env.NODE_ENV === "test") return saveProductPatentRecords(normalizedRecords); + + const response = await fetch("/api/intake/product-patents", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ records: normalizedRecords }), + }); + const payload = (await response.json().catch(() => null)) as { + success?: boolean; + data?: { records?: unknown[] }; + error?: { message?: string }; + } | null; + + if (!response.ok || !payload?.success || !Array.isArray(payload.data?.records)) { + throw new Error(payload?.error?.message || "专利台账保存失败"); + } + + const savedRecords = payload.data.records + .map((item) => normalizeProductPatentRecord(item)) + .filter((item): item is ProductPatentRecord => Boolean(item)); + saveProductPatentRecords(savedRecords); + return savedRecords; +} + export function mergeProductPatentRecordsWithProducts( records: ProductPatentRecord[], products: NonIntegratedProductRecord[], diff --git a/furniture-3d-system/src/app/intake/seriesComponentDetails.ts b/furniture-3d-system/src/app/intake/seriesComponentDetails.ts index 3d8e7c5b..7c73917d 100644 --- a/furniture-3d-system/src/app/intake/seriesComponentDetails.ts +++ b/furniture-3d-system/src/app/intake/seriesComponentDetails.ts @@ -31,6 +31,7 @@ export type SeriesDraftSkuFields = { }; type PreSkuComponentDetailSource = { + basicInfo?: Partial> | null; displaySummary?: Record | null; familySelection?: Record | null; bomPreview?: unknown[] | null; @@ -55,11 +56,6 @@ function buildComponentDetailsFromCodes( export function readPreSkuComponentDetails( preSku: PreSkuComponentDetailSource, ): NonIntegratedSkuComponentDetail[] { - const fromDisplaySummary = normalizeNonIntegratedSkuComponentDetails( - preSku.displaySummary?.componentDetails, - ); - if (fromDisplaySummary.length > 0) return fromDisplaySummary; - const fromBomPreview = normalizeNonIntegratedSkuComponentDetails( preSku.bomPreview, ); @@ -84,6 +80,11 @@ function readPreSkuStringField( preSku: PreSkuComponentDetailSource, key: string, ) { + const basicInfoKey = key === "skuCode" ? "formalSkuCode" : key; + const basicInfoValue = preSku.basicInfo?.[basicInfoKey as keyof NonNullable]; + const normalizedBasicInfoValue = normalizePreSkuTextValue(basicInfoValue); + if (normalizedBasicInfoValue) return normalizedBasicInfoValue; + const summaryValue = preSku.displaySummary?.[key]; const normalizedSummaryValue = normalizePreSkuTextValue(summaryValue); if (normalizedSummaryValue) return normalizedSummaryValue; diff --git a/furniture-3d-system/src/app/intake/spuRuleLibraryLogic.ts b/furniture-3d-system/src/app/intake/spuRuleLibraryLogic.ts index 1c222c8b..dbe52964 100644 --- a/furniture-3d-system/src/app/intake/spuRuleLibraryLogic.ts +++ b/furniture-3d-system/src/app/intake/spuRuleLibraryLogic.ts @@ -1,9 +1,13 @@ import { SPU_RULE_LIBRARY_SEED_RULES } from "./spuRuleLibrarySeed"; +import { + listIntakeReferenceLibraryItems, + replaceIntakeReferenceLibraryItems, +} from "@/lib/intake-reference-library-client"; -export const SPU_RULE_LIBRARY_STORAGE_KEY = "intake.spuRuleLibrary.rules"; export const SPU_RULE_LIBRARY_UPDATED_EVENT = "intake:spuRuleLibraryUpdated"; +export const SPU_RULE_REFERENCE_LIBRARY_TYPE = "SPU_RULE"; -type SpuRuleLibraryRuleLike = { +export type SpuRuleLibraryRuleLike = { id?: string; productCategory?: string; categoryCode?: string; @@ -12,6 +16,11 @@ type SpuRuleLibraryRuleLike = { [key: string]: unknown; }; +export type SpuRuleLibraryServerSnapshot = { + rules: SpuRuleLibraryRuleLike[]; + hasServerRows: boolean; +}; + type ProductSpuSource = { id?: string; spu?: string; @@ -24,6 +33,8 @@ export type SpuRuleAssignment = { spu: string; }; +let serverRuleRuntimeCache: SpuRuleLibraryRuleLike[] | null = null; + function normalizeText(value: unknown) { return typeof value === "string" ? value.trim() : ""; } @@ -54,40 +65,135 @@ function getSeedRules() { ); } +function readPayloadRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +} + +function readPayloadText(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +export function normalizeSpuRuleLibraryRules(rules: unknown[]) { + const normalizedRules = rules + .map((rule) => normalizeRule(rule as SpuRuleLibraryRuleLike)) + .filter((rule): rule is SpuRuleLibraryRuleLike => Boolean(rule)); + + return normalizedRules.length > 0 ? normalizedRules : getSeedRules(); +} + export function readSpuRuleLibraryRules() { - if (typeof window === "undefined") return getSeedRules(); - - try { - const storedValue = window.localStorage.getItem(SPU_RULE_LIBRARY_STORAGE_KEY); - if (!storedValue) return getSeedRules(); - - const parsedValue = JSON.parse(storedValue); - if (!Array.isArray(parsedValue)) return getSeedRules(); - - const rules = parsedValue - .map((rule) => normalizeRule(rule)) - .filter((rule): rule is SpuRuleLibraryRuleLike => Boolean(rule)); - - return rules.length > 0 ? rules : getSeedRules(); - } catch { - return getSeedRules(); - } + if (serverRuleRuntimeCache) return serverRuleRuntimeCache; + return getSeedRules(); } export function writeSpuRuleLibraryRules(rules: SpuRuleLibraryRuleLike[]) { - if (typeof window === "undefined") return; + serverRuleRuntimeCache = normalizeSpuRuleLibraryRules(rules); - try { - window.localStorage.setItem( - SPU_RULE_LIBRARY_STORAGE_KEY, - JSON.stringify(rules), - ); + if (typeof window !== "undefined") { window.dispatchEvent(new Event(SPU_RULE_LIBRARY_UPDATED_EVENT)); - } catch { - // 本地规则库缓存不可写时,产品详情页仍然可以显示当前计算结果。 } } +function writeServerSpuRuleLibraryRules(rules: SpuRuleLibraryRuleLike[]) { + serverRuleRuntimeCache = rules; + + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(SPU_RULE_LIBRARY_UPDATED_EVENT)); + } +} + +export function clearSpuRuleLibraryRuntimeCache() { + serverRuleRuntimeCache = null; +} + +export function decodeSpuRuleReferenceLibraryRows( + rows: Awaited>, +): SpuRuleLibraryServerSnapshot { + const rules = rows + .filter((row) => row.isActive !== false) + .map((row) => { + const payload = readPayloadRecord(row.payload); + const rulePayload = readPayloadRecord(payload.rule); + const source = Object.keys(rulePayload).length ? rulePayload : payload; + const productCategory = row.name; + + if (!productCategory) return null; + + return normalizeRule({ + ...source, + id: row.itemKey, + productCategory, + categoryCode: row.code, + }); + }) + .filter((rule): rule is SpuRuleLibraryRuleLike => Boolean(rule)); + + const hasServerRows = rows.length > 0; + + return { + rules: hasServerRows ? rules : getSeedRules(), + hasServerRows, + }; +} + +export function buildSpuRuleReferenceLibraryRows( + rules: SpuRuleLibraryRuleLike[], +) { + return normalizeSpuRuleLibraryRules(rules).map((rule, index) => { + const itemKey = + normalizeText(rule.id) || + normalizeText(rule.productCategory) || + `rule-${index + 1}`; + + return { + itemKey, + code: normalizeText(rule.categoryCode).toUpperCase(), + name: normalizeText(rule.productCategory), + category: normalizeText(rule.secondCategory), + payload: { + kind: SPU_RULE_REFERENCE_LIBRARY_TYPE, + rule, + }, + sortOrder: index, + isActive: rule.enabled !== false, + }; + }); +} + +export async function listSpuRuleLibraryRulesFromServer() { + const rows = await listIntakeReferenceLibraryItems(SPU_RULE_REFERENCE_LIBRARY_TYPE); + return decodeSpuRuleReferenceLibraryRows(rows); +} + +export async function replaceSpuRuleLibraryRulesOnServer( + rules: SpuRuleLibraryRuleLike[], +) { + const rows = await replaceIntakeReferenceLibraryItems( + SPU_RULE_REFERENCE_LIBRARY_TYPE, + buildSpuRuleReferenceLibraryRows(rules), + ); + const snapshot = decodeSpuRuleReferenceLibraryRows(rows); + if (snapshot.hasServerRows) { + writeServerSpuRuleLibraryRules(snapshot.rules); + } else { + clearSpuRuleLibraryRuntimeCache(); + } + return snapshot; +} + +export async function syncSpuRuleLibraryRulesFromServer() { + const snapshot = await listSpuRuleLibraryRulesFromServer(); + if (snapshot.hasServerRows) { + writeServerSpuRuleLibraryRules(snapshot.rules); + } + return snapshot; +} + +export async function persistCurrentSpuRuleLibraryRulesToServer() { + return replaceSpuRuleLibraryRulesOnServer(readSpuRuleLibraryRules()); +} + function isSpuPrefix(value: unknown) { return /^[A-Za-z][A-Za-z0-9]*$/.test(normalizeText(value)); } @@ -183,34 +289,3 @@ export function getEstimatedSpuAssignment({ }; } -export function reserveSpuAssignmentForProductCategory({ - productCategory, - products, - currentProductId, -}: { - productCategory: string; - products: ProductSpuSource[]; - currentProductId?: string | null; -}) { - const rules = readSpuRuleLibraryRules(); - const assignment = getEstimatedSpuAssignment({ - productCategory, - products, - currentProductId, - }); - - if (!assignment) return null; - - writeSpuRuleLibraryRules( - rules.map((rule) => - normalizeText(rule.productCategory) === assignment.productCategory - ? { - ...rule, - spuIndex: Math.max(normalizeNumber(rule.spuIndex), assignment.index), - } - : rule, - ), - ); - - return assignment; -} diff --git a/furniture-3d-system/src/app/products/[id]/ProductDetailClient.workspace.tsx b/furniture-3d-system/src/app/products/[id]/ProductDetailClient.workspace.tsx index 54d6d796..13fedeb6 100644 --- a/furniture-3d-system/src/app/products/[id]/ProductDetailClient.workspace.tsx +++ b/furniture-3d-system/src/app/products/[id]/ProductDetailClient.workspace.tsx @@ -8,6 +8,7 @@ import { PanelEditor, type PanelData } from '@/components/panels' import { PackagingEditor, type PackagingPlanData, type BoxData } from '@/components/packaging' import EditSKUModal from '@/components/products/EditSKUModal' import { getApiErrorMessage } from '@/lib/utils' +import { buildSkuDisplayTags } from '@/lib/sku-display-tags' import { ProductDetailTopBar } from './components/ProductDetailTopBar' import { ProductInfoCard } from './components/ProductInfoCard' import { CreateShareLinkModal } from './components/CreateShareLinkModal' @@ -31,7 +32,8 @@ interface SKUData { price: number costPrice: number | null stock: number - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] images: string[] modelUrl: string | null isActive: boolean @@ -43,6 +45,15 @@ interface SKUData { createdAt: string } +interface SKUUpdatePayload extends Omit { + displayTags?: string[] + assetProfile?: { + specName?: string | null + color?: string | null + material?: string | null + } | null +} + interface ProductDetailClientProps { product: { id: string @@ -128,8 +139,17 @@ export default function ProductDetailClient({ }, []) // SKU 更新成功回调 - const handleSkuUpdateSuccess = useCallback((updatedSku: SKUData) => { - setSkus(prev => prev.map(s => s.id === updatedSku.id ? updatedSku : s)) + const handleSkuUpdateSuccess = useCallback((updatedSku: SKUUpdatePayload) => { + const legacyAttributes = updatedSku.legacyAttributes ?? null + const normalizedSku: SKUData = { + ...updatedSku, + legacyAttributes, + displayTags: updatedSku.displayTags ?? buildSkuDisplayTags({ + assetProfile: updatedSku.assetProfile, + legacyAttributes, + }), + } + setSkus(prev => prev.map(s => s.id === normalizedSku.id ? normalizedSku : s)) router.refresh() }, [router]) @@ -526,11 +546,11 @@ export default function ProductDetailClient({ {sku.skuCode} {/* 规格属性展示 */} - {sku.attributes && Object.keys(sku.attributes).length > 0 && ( + {sku.displayTags.length > 0 && (
- {Object.entries(sku.attributes).map(([key, value]) => ( + {sku.displayTags.map((value) => ( { - const attrs = (sku.attributes || {}) as Record - return Object.prototype.hasOwnProperty.call(attrs, '_skuCadData') - }) ?? null + product.skus.find((sku) => hasSkuCadCompatibilityData(sku.attributes)) ?? null const hasSkuScopedStructure = Boolean(skuStructureEntry) return ( @@ -202,42 +215,28 @@ export default async function ProductDetailPage({ params }: ProductPageProps) { notes: p.notes, isDefault: p.isDefault, }))} - skus={product.skus.map(sku => ({ - id: sku.id, - skuCode: sku.skuCode, - price: sku.price.toNumber(), - costPrice: canViewSensitive ? (sku.costPrice?.toNumber() || null) : null, - stock: canViewSensitive ? sku.stock : 0, - attributes: (() => { - const attrs = (sku.attributes || {}) as Record - const display = Object.fromEntries( - Object.entries(attrs).filter(([key, value]) => !key.startsWith('_') && typeof value === 'string') - ) as Record - return Object.keys(display).length > 0 ? display : null - })(), - cadExcludedHolesJson: (() => { - const attrs = (sku.attributes || {}) as Record - const v = attrs['_cadExcludedHoles'] - return typeof v === 'string' && v.trim().length > 0 ? v : null - })(), - cadExcludedHolesCount: (() => { - const attrs = (sku.attributes || {}) as Record - const v = attrs['_cadExcludedHolesCount'] - if (typeof v === 'string') { - const n = Number(v) - return Number.isFinite(n) ? n : 0 - } - if (typeof v === 'number') { - return Number.isFinite(v) ? v : 0 - } - return 0 - })(), - images: sku.images, - modelUrl: sku.modelUrl, - isActive: sku.isActive, - owner: sku.owner, - createdAt: sku.createdAt.toISOString(), - }))} + skus={product.skus.map(sku => { + const legacyAttributes = toLegacySkuDisplayAttributes(sku.attributes) + return { + id: sku.id, + skuCode: sku.skuCode, + price: sku.price.toNumber(), + costPrice: canViewSensitive ? (sku.costPrice?.toNumber() || null) : null, + stock: canViewSensitive ? sku.stock : 0, + legacyAttributes, + displayTags: buildSkuDisplayTags({ + assetProfile: sku.assetProfile, + legacyAttributes, + }), + cadExcludedHolesJson: readSkuCadExcludedHolesJson(sku.attributes), + cadExcludedHolesCount: readSkuCadExcludedHolesCount(sku.attributes), + images: sku.images, + modelUrl: sku.modelUrl, + isActive: sku.isActive, + owner: sku.owner, + createdAt: sku.createdAt.toISOString(), + } + })} />
) diff --git a/furniture-3d-system/src/app/products/[id]/product-detail-client.types.ts b/furniture-3d-system/src/app/products/[id]/product-detail-client.types.ts index ea09639c..500cf645 100644 --- a/furniture-3d-system/src/app/products/[id]/product-detail-client.types.ts +++ b/furniture-3d-system/src/app/products/[id]/product-detail-client.types.ts @@ -14,7 +14,7 @@ export interface SKUData { price: number costPrice: number | null stock: number - attributes: Record | null + legacyAttributes: Record | null cadExcludedHolesJson?: string | null cadExcludedHolesCount?: number images: string[] diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/SKUDetailClient.workspace.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/SKUDetailClient.workspace.tsx index dfae9921..d6e8f661 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/SKUDetailClient.workspace.tsx +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/SKUDetailClient.workspace.tsx @@ -94,7 +94,8 @@ interface SKUFullInfo { costPrice: number | null stock: number isActive: boolean - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] images: string[] modelUrl: string | null } @@ -103,7 +104,8 @@ interface SKUBrief { id: string skuCode: string price: number - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] } interface SKUDetailClientProps { diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUReadOnlySidebar.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUReadOnlySidebar.tsx index b4822ef4..10fd6461 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUReadOnlySidebar.tsx +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUReadOnlySidebar.tsx @@ -12,7 +12,8 @@ interface SKUReadOnlySidebarProps { id: string skuCode: string isActive: boolean - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] price: number costPrice: number | null stock: number @@ -99,11 +100,11 @@ export function SKUReadOnlySidebar({ {sku.isActive ? '在售' : '停售'} - {sku.attributes && Object.keys(sku.attributes).length > 0 && ( + {sku.displayTags.length > 0 && (
- {Object.entries(sku.attributes).map(([key, value]) => ( + {sku.displayTags.map((value) => ( diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUSwitchDrawer.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUSwitchDrawer.tsx index 0be22b63..18af01e1 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUSwitchDrawer.tsx +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/components/SKUSwitchDrawer.tsx @@ -9,7 +9,8 @@ interface SKUSwitchDrawerProps { id: string skuCode: string price: number - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] }> currentSkuId: string onClose: () => void @@ -68,11 +69,11 @@ export function SKUSwitchDrawer({ ¥{skuItem.price.toFixed(2)}
- {skuItem.attributes && Object.keys(skuItem.attributes).length > 0 && ( + {skuItem.displayTags.length > 0 && (
- {Object.entries(skuItem.attributes).map(([key, value]) => ( + {skuItem.displayTags.map((value) => ( diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx index de96d364..a6f2b310 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx @@ -13,9 +13,12 @@ import { } from '@/lib/panel-material-assignments' import { enrichPanelsWithSkuCadSpatial, - filterDisplaySkuAttributes, readSkuCadDataFromAttributes, } from '@/lib/sku-cad-data' +import { + buildSkuDisplayTags, + toLegacySkuDisplayAttributes, +} from '@/lib/sku-display-tags' interface SKUDetailPageProps { params: Promise<{ id: string; skuId: string }> @@ -254,11 +257,19 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) { select: { id: true, skuCode: true, + name: true, price: true, costPrice: true, // 敏感:成本价 stock: true, // 敏感:库存 isActive: true, attributes: true, + assetProfile: { + select: { + specName: true, + color: true, + material: true, + }, + }, images: true, modelUrl: true, }, @@ -293,6 +304,7 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) { }) const rawCurrentSkuAttributes = currentSku.attributes as Record | null + const currentLegacyAttributes = toLegacySkuDisplayAttributes(rawCurrentSkuAttributes) const skuCadData = readSkuCadDataFromAttributes(rawCurrentSkuAttributes) const scopedPanels = await prisma.panel.findMany({ where: { productId, skuId }, @@ -410,16 +422,27 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) { costPrice: canViewSensitive && currentSku.costPrice ? Number(currentSku.costPrice) : null, stock: canViewSensitive ? currentSku.stock : 0, isActive: currentSku.isActive, - attributes: filterDisplaySkuAttributes(rawCurrentSkuAttributes), + legacyAttributes: currentLegacyAttributes, + displayTags: buildSkuDisplayTags({ + assetProfile: currentSku.assetProfile, + legacyAttributes: currentLegacyAttributes, + }), images: currentSku.images, modelUrl: currentSku.modelUrl, }} - allSkus={product.skus.map(s => ({ - id: s.id, - skuCode: s.skuCode, - price: Number(s.price), - attributes: filterDisplaySkuAttributes(s.attributes as Record | null), - }))} + allSkus={product.skus.map(s => { + const legacyAttributes = toLegacySkuDisplayAttributes(s.attributes) + return { + id: s.id, + skuCode: s.skuCode, + price: Number(s.price), + legacyAttributes, + displayTags: buildSkuDisplayTags({ + assetProfile: s.assetProfile, + legacyAttributes, + }), + } + })} steps={steps} panels={displayPanels} materialAssignmentsByNode={materialAssignmentsByNode} diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/sku-detail-client.types.ts b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/sku-detail-client.types.ts index 8f1035e5..fa0a528e 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/sku-detail-client.types.ts +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/sku-detail-client.types.ts @@ -18,7 +18,8 @@ export interface SKUFullInfo { costPrice: number | null stock: number isActive: boolean - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] images: string[] modelUrl: string | null } @@ -27,7 +28,8 @@ export interface SKUBrief { id: string skuCode: string price: number - attributes: Record | null + legacyAttributes: Record | null + displayTags: string[] } export interface SKUDetailClientProps { diff --git a/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx b/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx index 0b0fd5c8..3297fdd1 100644 --- a/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx +++ b/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx @@ -19,21 +19,31 @@ interface ShareProduct { thumbnailUrl?: string | null } +interface PublicSkuAssetProfile { + specName?: string | null + color?: string | null + material?: string | null +} + // SKU 公开信息(不包含敏感数据:costPrice, stock 等) interface ShareSKU { id: string skuCode: string + name: string | null price: number // 注意:不包含 costPrice, stock, isActive 等敏感字段 - attributes: Record | null + assetProfile: PublicSkuAssetProfile | null + legacyAttributes: Record | null } // SKU 简要信息(用于切换) interface SKUBrief { id: string skuCode: string + name: string | null price: number - attributes: Record | null + assetProfile: PublicSkuAssetProfile | null + legacyAttributes: Record | null } interface SharePageClientProps { @@ -66,6 +76,24 @@ export function SharePageClient({ product, sku, allSkus, steps, panels, shareTok const [isPlaying, setIsPlaying] = useState(false) const [_progress, setProgress] = useState(0) + const getSkuBadges = useCallback((targetSku: ShareSKU | SKUBrief) => { + const profileBadges = [ + targetSku.assetProfile?.specName, + targetSku.assetProfile?.color, + targetSku.assetProfile?.material, + ] + .map((item) => item?.trim()) + .filter((item): item is string => Boolean(item)) + + if (profileBadges.length > 0) { + return Array.from(new Set(profileBadges)) + } + + return Object.values(targetSku.legacyAttributes ?? {}) + .map((item) => item.trim()) + .filter((item) => item.length > 0) + }, []) + // 初始化 3D 引擎 useEffect(() => { if (!containerRef.current) { @@ -400,11 +428,11 @@ export function SharePageClient({ product, sku, allSkus, steps, panels, shareTok ¥{sku.price.toFixed(2)}
- {sku.attributes && Object.keys(sku.attributes).length > 0 && ( + {getSkuBadges(sku).length > 0 && (
- {Object.entries(sku.attributes).map(([key, value]) => ( + {getSkuBadges(sku).map((value) => ( {value} @@ -544,11 +572,11 @@ export function SharePageClient({ product, sku, allSkus, steps, panels, shareTok ¥{s.price.toFixed(2)}
- {s.attributes && Object.keys(s.attributes).length > 0 && ( + {getSkuBadges(s).length > 0 && (
- {Object.entries(s.attributes).map(([key, value]) => ( + {getSkuBadges(s).map((value) => ( {value} diff --git a/furniture-3d-system/src/app/share/[token]/page.tsx b/furniture-3d-system/src/app/share/[token]/page.tsx index 3b1b5681..a35b5bcb 100644 --- a/furniture-3d-system/src/app/share/[token]/page.tsx +++ b/furniture-3d-system/src/app/share/[token]/page.tsx @@ -70,16 +70,20 @@ export default async function SharePage({ params, searchParams }: SharePageProps ? { id: currentSku.id, skuCode: currentSku.skuCode, + name: currentSku.name, price: Number(currentSku.price), - attributes: currentSku.attributes as Record | null, + assetProfile: currentSku.assetProfile, + legacyAttributes: currentSku.legacyAttributes, } : null } allSkus={product.skus.map((sku) => ({ id: sku.id, skuCode: sku.skuCode, + name: sku.name, price: Number(sku.price), - attributes: sku.attributes as Record | null, + assetProfile: sku.assetProfile, + legacyAttributes: sku.legacyAttributes, }))} steps={product.assemblySteps.map((step) => ({ id: `share-step-${step.order}`, diff --git a/furniture-3d-system/src/app/trash/TrashClient.tsx b/furniture-3d-system/src/app/trash/TrashClient.tsx index 864673d5..42a0e6c8 100644 --- a/furniture-3d-system/src/app/trash/TrashClient.tsx +++ b/furniture-3d-system/src/app/trash/TrashClient.tsx @@ -40,7 +40,7 @@ interface TrashSKU { expiresAt: string daysRemaining: number price: number - attributes: Record | null + legacyAttributes: Record | null } interface TrashAssetFamily { diff --git a/furniture-3d-system/src/app/warehouse/page.tsx b/furniture-3d-system/src/app/warehouse/page.tsx index 0eaf4dbc..958bd6f7 100644 --- a/furniture-3d-system/src/app/warehouse/page.tsx +++ b/furniture-3d-system/src/app/warehouse/page.tsx @@ -1,5 +1,6 @@ import { requireSession } from '@/lib/server-session' import { mapWorkbenchStatus, readPreSkuTextField, readPreviewNodes } from '@/lib/open-product/platform-view' +import { readPreSkuSkuFields } from '@/app/intake/seriesComponentDetails' import { assetCenterService, draftGroupService, warehouseService } from '@/server/services' import WarehouseClient from './WarehouseClient' @@ -95,20 +96,23 @@ export default async function WarehousePage() { const workbenchSkuList = draftGroupDetails .filter((detail): detail is NonNullable => detail !== null) .flatMap((detail) => - detail.preSkus.map((preSku) => ({ - id: preSku.id, - spuCode: - readPreSkuTextField(preSku, 'spuCode') - || readPreSkuTextField(preSku, 'spu') - || detail.draftGroup.code - || `SPU-${detail.draftGroup.code.slice(-4)}`, - code: readPreSkuTextField(preSku, 'skuCode') || preSku.customName || preSku.defaultName, - name: preSku.customName || preSku.defaultName, - seriesName: readPreSkuTextField(preSku, 'seriesName') || detail.draftGroup.name, - draftGroupName: detail.draftGroup.name, - status: mapWorkbenchStatus(preSku.status), - componentCodes: preSku.componentCodes ?? [], - })), + detail.preSkus.map((preSku) => { + const skuFields = readPreSkuSkuFields(preSku) + return { + id: preSku.id, + spuCode: + readPreSkuTextField(preSku, 'spuCode') + || readPreSkuTextField(preSku, 'spu') + || detail.draftGroup.code + || `SPU-${detail.draftGroup.code.slice(-4)}`, + code: skuFields.skuCode || preSku.customName || preSku.defaultName, + name: preSku.customName || preSku.defaultName, + seriesName: readPreSkuTextField(preSku, 'seriesName') || detail.draftGroup.name, + draftGroupName: detail.draftGroup.name, + status: mapWorkbenchStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], + } + }), ) const firstPreSkuWithPreview = draftGroupDetails diff --git a/furniture-3d-system/src/components/products/EditSKUModal.tsx b/furniture-3d-system/src/components/products/EditSKUModal.tsx index b9f72cab..d408ecbf 100644 --- a/furniture-3d-system/src/components/products/EditSKUModal.tsx +++ b/furniture-3d-system/src/components/products/EditSKUModal.tsx @@ -22,7 +22,7 @@ interface SKUData { price: number costPrice: number | null stock: number - attributes: Record | null + legacyAttributes: Record | null images: string[] modelUrl: string | null isActive: boolean @@ -59,26 +59,17 @@ export default function EditSKUModal({ costPrice: '', stock: '', isActive: true, - // 动态属性 - attributeKeys: [] as string[], - attributeValues: [] as string[], }) // 初始化表单数据 useEffect(() => { if (sku) { - const attributes = sku.attributes || {} - const keys = Object.keys(attributes) - const values = keys.map(k => attributes[k]) - setFormData({ skuCode: sku.skuCode, price: sku.price.toString(), costPrice: sku.costPrice?.toString() || '', stock: sku.stock.toString(), isActive: sku.isActive, - attributeKeys: keys.length > 0 ? keys : [''], - attributeValues: values.length > 0 ? values : [''], }) // 重置错误状态 @@ -92,39 +83,6 @@ export default function EditSKUModal({ setFormData(prev => ({ ...prev, [field]: value })) }, []) - // 添加属性 - const handleAddAttribute = useCallback(() => { - setFormData(prev => ({ - ...prev, - attributeKeys: [...prev.attributeKeys, ''], - attributeValues: [...prev.attributeValues, ''], - })) - }, []) - - // 删除属性 - const handleRemoveAttribute = useCallback((index: number) => { - setFormData(prev => ({ - ...prev, - attributeKeys: prev.attributeKeys.filter((_, i) => i !== index), - attributeValues: prev.attributeValues.filter((_, i) => i !== index), - })) - }, []) - - // 更新属性 - const handleAttributeChange = useCallback((index: number, type: 'key' | 'value', value: string) => { - setFormData(prev => { - if (type === 'key') { - const newKeys = [...prev.attributeKeys] - newKeys[index] = value - return { ...prev, attributeKeys: newKeys } - } else { - const newValues = [...prev.attributeValues] - newValues[index] = value - return { ...prev, attributeValues: newValues } - } - }) - }, []) - // 清除错误状态 const clearError = useCallback(() => { setSubmitError(null) @@ -166,14 +124,6 @@ export default function EditSKUModal({ setIsSubmitting(true) try { - // 构建属性对象 - const attributes: Record = {} - formData.attributeKeys.forEach((key, index) => { - if (key.trim() && formData.attributeValues[index]?.trim()) { - attributes[key.trim()] = formData.attributeValues[index].trim() - } - }) - const response = await fetch(`/api/skus/${sku.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -183,7 +133,6 @@ export default function EditSKUModal({ costPrice: formData.costPrice ? parseFloat(formData.costPrice) : null, stock: parseInt(formData.stock), isActive: formData.isActive, - attributes, }), }) @@ -316,56 +265,6 @@ export default function EditSKUModal({
- {/* 规格属性 */} -
-
- 规格属性 - -
- - - 定义 SKU 的规格属性,如颜色、尺寸等 - - -
- {formData.attributeKeys.map((key, index) => ( -
- handleAttributeChange(index, 'key', e.target.value)} - placeholder="属性名(如:颜色)" - className="flex-1" - /> - handleAttributeChange(index, 'value', e.target.value)} - placeholder="属性值(如:红色)" - className="flex-1" - /> - {formData.attributeKeys.length > 1 && ( - - )} -
- ))} -
-
- {/* 负责人信息(只读) */} {sku.owner && (
diff --git a/furniture-3d-system/src/components/ui/ProductListItem.tsx b/furniture-3d-system/src/components/ui/ProductListItem.tsx index e57309fd..9702491a 100644 --- a/furniture-3d-system/src/components/ui/ProductListItem.tsx +++ b/furniture-3d-system/src/components/ui/ProductListItem.tsx @@ -46,7 +46,8 @@ interface ProductListItemProps { price: number stock: number isActive: boolean - attributes?: Record | null + legacyAttributes?: Record | null + displayTags?: string[] }> } onEdit?: (product: ProductListItemProps['product']) => void diff --git a/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts b/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts index 70c0bbe0..2f3f1161 100644 --- a/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts +++ b/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts @@ -1,6 +1,9 @@ import { buildPanelsFromSkuCadData, buildPersistedSkuCadData, + hasSkuCadCompatibilityData, + readSkuCadExcludedHolesCount, + readSkuCadExcludedHolesJson, readSkuCadDataFromAttributes, SKU_CAD_DATA_ATTRIBUTE_KEY, } from '../sku-cad-data' @@ -217,6 +220,20 @@ describe('sku-cad-data', () => { ) }) + it('把旧 SKU attributes 中的 CAD 兼容元数据收拢为显式 helper', () => { + const attributes = { + [SKU_CAD_DATA_ATTRIBUTE_KEY]: { panels: [] }, + _cadExcludedHoles: '["hole-1"]', + _cadExcludedHolesCount: '2', + } + + expect(hasSkuCadCompatibilityData(attributes)).toBe(true) + expect(readSkuCadExcludedHolesJson(attributes)).toBe('["hole-1"]') + expect(readSkuCadExcludedHolesCount(attributes)).toBe(2) + expect(readSkuCadExcludedHolesJson({ _cadExcludedHoles: ' ' })).toBeNull() + expect(readSkuCadExcludedHolesCount({ _cadExcludedHolesCount: 'bad' })).toBe(0) + }) + it('会为只有孔位约束、没有显式 panel 记录的数据补出占位板件', () => { const cadData = buildPersistedSkuCadData({ pluginVersion: '2.1.0', diff --git a/furniture-3d-system/src/lib/accessory-library.test.ts b/furniture-3d-system/src/lib/accessory-library.test.ts index 2b75dbee..c4327539 100644 --- a/furniture-3d-system/src/lib/accessory-library.test.ts +++ b/furniture-3d-system/src/lib/accessory-library.test.ts @@ -1,6 +1,7 @@ import { applyNonIntegratedBomAccessoryMetrics, buildNonIntegratedBomAccessoryOptions, + decodeAccessoryReferenceLibraryRows, findNonIntegratedBomAccessoryOption, type AccessoryLibraryItem, } from '@/lib/accessory-library' @@ -109,4 +110,33 @@ describe('accessory-library helpers', () => { expect(row.unitPrice).toBe('0.2') expect(row.material).toBe('3.5*12') }) + + it('decodes accessory identity from structured row fields before stale payload mirrors', () => { + const items = decodeAccessoryReferenceLibraryRows([ + { + id: 'row-1', + itemKey: 'ACC-001', + code: 'A1', + name: '结构化螺丝', + letter: 'A', + updatedAt: '2026-05-09T00:00:00.000Z', + isActive: true, + payload: { + id: 'OLD-ID', + code: 'OLD', + name: '旧螺丝', + letter: 'Z', + price: '0.11', + }, + }, + ]) + + expect(items[0]).toMatchObject({ + id: 'ACC-001', + code: 'A1', + name: '结构化螺丝', + letter: 'A', + price: '0.11', + }) + }) }) diff --git a/furniture-3d-system/src/lib/accessory-library.ts b/furniture-3d-system/src/lib/accessory-library.ts index 98b84233..99546c52 100644 --- a/furniture-3d-system/src/lib/accessory-library.ts +++ b/furniture-3d-system/src/lib/accessory-library.ts @@ -1,7 +1,6 @@ -export const ACCESSORY_LIBRARY_STORAGE_KEY = - 'formal-intake-accessory-library-items' export const ACCESSORY_LIBRARY_CHANGED_EVENT = 'formal-intake-accessory-library:changed' +export const ACCESSORY_REFERENCE_LIBRARY_TYPE = 'ACCESSORY' export type AccessoryLibraryItem = { id: string @@ -52,6 +51,17 @@ type AccessoryLikeRow = { partImageName?: string } +type AccessoryReferenceLibraryRow = { + id: string + itemKey: string + code: string + name: string + letter: string + payload: unknown + updatedAt: string + isActive?: boolean | null +} + function normalizeCode(value?: string | null) { return (value ?? '').trim().toUpperCase() } @@ -60,59 +70,68 @@ function normalizeName(value?: string | null) { return (value ?? '').trim().toLowerCase() } -function readRawAccessoryItems() { - if (typeof window === 'undefined') return [] - - try { - const rawValue = window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) - if (!rawValue) return [] - - const parsedValue = JSON.parse(rawValue) - if (!Array.isArray(parsedValue)) return [] - - return parsedValue - } catch { - return [] - } +function readPayloadRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record } -export function readAccessoryLibraryItems() { - return readRawAccessoryItems().filter( - (item): item is AccessoryLibraryItem => - item && - typeof item.id === 'string' && - typeof item.letter === 'string' && - typeof item.code === 'string' && - typeof item.name === 'string' && - (item.screwCategory === undefined || - typeof item.screwCategory === 'string') && - (item.headType === undefined || typeof item.headType === 'string') && - (item.drive === undefined || typeof item.drive === 'string') && - (item.tail === undefined || typeof item.tail === 'string') && - (item.color === undefined || typeof item.color === 'string') && - (item.material === undefined || typeof item.material === 'string') && - (item.finish === undefined || typeof item.finish === 'string') && - (item.tooth === undefined || typeof item.tooth === 'string') && - (item.diameter === undefined || typeof item.diameter === 'string') && - (item.length === undefined || typeof item.length === 'string') && - (item.usages === undefined || - (Array.isArray(item.usages) && - item.usages.every((value: unknown) => typeof value === 'string'))) && - (item.preDrill === undefined || typeof item.preDrill === 'string') && - (item.environments === undefined || - (Array.isArray(item.environments) && - item.environments.every( - (value: unknown) => typeof value === 'string', - ))) && - typeof item.price === 'string' && - typeof item.imageUrl === 'string' && - typeof item.imageName === 'string' && - typeof item.updatedAt === 'string', - ) +function readPayloadText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function readPayloadStringArray(value: unknown) { + if (!Array.isArray(value)) return undefined + const values = value.filter((item): item is string => typeof item === 'string') + return values.length ? values : undefined +} + +export function decodeAccessoryReferenceLibraryRows( + rows: AccessoryReferenceLibraryRow[], +) { + return rows + .filter((row) => row.isActive !== false) + .map((row) => { + const payload = readPayloadRecord(row.payload) + const itemPayload = readPayloadRecord(payload.item) + const source = Object.keys(itemPayload).length ? itemPayload : payload + const id = row.itemKey || row.id + const letter = row.letter.toUpperCase() + const code = row.code + const name = row.name + + if (!id || !letter || !code || !name) return null + + const item: AccessoryLibraryItem = { + id, + letter, + code, + name, + screwCategory: readPayloadText(source.screwCategory), + headType: readPayloadText(source.headType), + drive: readPayloadText(source.drive), + tail: readPayloadText(source.tail), + color: readPayloadText(source.color), + material: readPayloadText(source.material), + finish: readPayloadText(source.finish), + tooth: readPayloadText(source.tooth), + diameter: readPayloadText(source.diameter), + length: readPayloadText(source.length), + usages: readPayloadStringArray(source.usages), + preDrill: readPayloadText(source.preDrill), + environments: readPayloadStringArray(source.environments), + price: readPayloadText(source.price), + imageUrl: readPayloadText(source.imageUrl), + imageName: readPayloadText(source.imageName), + updatedAt: readPayloadText(source.updatedAt) || row.updatedAt, + } + + return item + }) + .filter((item): item is AccessoryLibraryItem => item !== null) } export function buildNonIntegratedBomAccessoryOptions( - items = readAccessoryLibraryItems(), + items: AccessoryLibraryItem[], ) { return items .map((item) => { diff --git a/furniture-3d-system/src/lib/edge-banding-library.test.ts b/furniture-3d-system/src/lib/edge-banding-library.test.ts index bb26fde7..ed0c677c 100644 --- a/furniture-3d-system/src/lib/edge-banding-library.test.ts +++ b/furniture-3d-system/src/lib/edge-banding-library.test.ts @@ -1,4 +1,5 @@ import { + buildEdgeBandingReferenceItemKey, buildNonIntegratedBomEdgeBandingOptions, createEdgeBandingLibraryItem, parseBoardThicknessMmFromText, @@ -27,4 +28,32 @@ describe('edge-banding-library', () => { expect(options[0]?.compatibleThicknessMm).toBe(12) expect(options[0]?.unitPrice).toBe('0.35') }) + + it('builds a reference-library identity from business spec instead of UI id', () => { + const first = createEdgeBandingLibraryItem( + { + name: 'PVC封边条', + spec: '22 × 1', + unit: '米', + unitPrice: '0.35', + compatibleThicknessMm: '12MM', + }, + { id: 'temporary-ui-id-a' }, + ) + const second = createEdgeBandingLibraryItem( + { + name: 'PVC封边条', + spec: '22×1', + unit: '米', + unitPrice: '0.35', + compatibleThicknessMm: '12', + }, + { id: 'temporary-ui-id-b' }, + ) + + expect(buildEdgeBandingReferenceItemKey(first)).toBe( + buildEdgeBandingReferenceItemKey(second), + ) + expect(buildEdgeBandingReferenceItemKey(first)).toBe('ITEM:22-1:12') + }) }) diff --git a/furniture-3d-system/src/lib/edge-banding-library.ts b/furniture-3d-system/src/lib/edge-banding-library.ts index 2d92bd0c..90f7a2b4 100644 --- a/furniture-3d-system/src/lib/edge-banding-library.ts +++ b/furniture-3d-system/src/lib/edge-banding-library.ts @@ -1,7 +1,7 @@ -export const EDGE_BANDING_LIBRARY_STORAGE_KEY = - 'formal-intake-edge-banding-library-items' export const EDGE_BANDING_LIBRARY_CHANGED_EVENT = 'formal-intake-edge-banding-library:changed' +export const EDGE_BANDING_REFERENCE_LIBRARY_TYPE = 'EDGE_BANDING' +export const EDGE_BANDING_REFERENCE_ITEM_CATEGORY = 'ITEM' export type EdgeBandingLibraryItem = { id: string @@ -27,11 +27,13 @@ export interface NonIntegratedBomEdgeBandingOption { searchText: string } -type EdgeBandingLibraryPersistedValue = - | EdgeBandingLibraryItem[] - | { - items?: EdgeBandingLibraryItem[] - } +type EdgeBandingReferenceLibraryRow = { + itemKey: string + category: string + payload: unknown + updatedAt: string + isActive?: boolean | null +} function normalizeText(value?: string | null) { return (value ?? '').trim().toLowerCase() @@ -115,6 +117,27 @@ export function createEdgeBandingLibraryItemId() { return `edge-banding-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } +function normalizeReferenceKeyPart(value?: string | null) { + const normalized = normalizeDisplayText(value).toUpperCase() + return normalized + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'UNKNOWN' +} + +export function buildEdgeBandingReferenceItemKey( + item: Pick, +) { + const thickness = formatEdgeBandingNumber( + parseBoardThicknessMmFromText(item.compatibleThicknessMm), + ) + return [ + 'ITEM', + normalizeReferenceKeyPart(item.spec), + normalizeReferenceKeyPart(thickness || item.compatibleThicknessMm), + ].join(':') +} + export function createEdgeBandingLibraryItem( draft: Partial, options?: { @@ -157,46 +180,37 @@ export function buildEdgeBandingLibraryItems(items: EdgeBandingLibraryItem[]) { .filter((item): item is EdgeBandingLibraryItem => item !== null) } -function readPersistedEdgeBandingLibraryValue(): EdgeBandingLibraryPersistedValue | null { - if (typeof window === 'undefined') return null - - try { - const rawValue = window.localStorage.getItem(EDGE_BANDING_LIBRARY_STORAGE_KEY) - if (!rawValue) return null - - const parsedValue = JSON.parse(rawValue) - if (Array.isArray(parsedValue)) return parsedValue - if (parsedValue && typeof parsedValue === 'object') return parsedValue - } catch { - return null - } - - return null +function readPayloadRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record } -function resolvePersistedEdgeBandingItems( - persistedValue: EdgeBandingLibraryPersistedValue | null, +function readPayloadText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +export function decodeEdgeBandingReferenceLibraryItems( + rows: EdgeBandingReferenceLibraryRow[], ) { - if (Array.isArray(persistedValue)) return persistedValue - if (Array.isArray(persistedValue?.items)) return persistedValue.items - return [] -} + const items = rows + .filter((row) => row.isActive !== false) + .filter((row) => { + const payload = readPayloadRecord(row.payload) + const kind = readPayloadText(payload.kind) || row.category + return kind === EDGE_BANDING_REFERENCE_ITEM_CATEGORY + }) + .map((row) => { + const payload = readPayloadRecord(row.payload) + const itemPayload = readPayloadRecord(payload.item) + return createEdgeBandingLibraryItem(itemPayload, { + id: + readPayloadText(itemPayload.id) || + row.itemKey.replace(/^item:/, ''), + updatedAt: readPayloadText(itemPayload.updatedAt) || row.updatedAt, + }) + }) -export function readEdgeBandingLibraryItems() { - return buildEdgeBandingLibraryItems( - resolvePersistedEdgeBandingItems(readPersistedEdgeBandingLibraryValue()), - ) -} - -export function persistEdgeBandingLibraryItems(items: EdgeBandingLibraryItem[]) { - if (typeof window === 'undefined') return - - const normalizedItems = buildEdgeBandingLibraryItems(items) - window.localStorage.setItem( - EDGE_BANDING_LIBRARY_STORAGE_KEY, - JSON.stringify(normalizedItems), - ) - window.dispatchEvent(new Event(EDGE_BANDING_LIBRARY_CHANGED_EVENT)) + return buildEdgeBandingLibraryItems(items) } export function buildEdgeBandingDisplayName( @@ -215,7 +229,7 @@ export function buildEdgeBandingSelectionLabel(item: EdgeBandingLibraryItem) { } export function buildNonIntegratedBomEdgeBandingOptions( - items = readEdgeBandingLibraryItems(), + items: EdgeBandingLibraryItem[], ) { return items .map((item) => { diff --git a/furniture-3d-system/src/lib/non-integrated-bom-steel-materials.ts b/furniture-3d-system/src/lib/non-integrated-bom-steel-materials.ts index ac093993..c57aa200 100644 --- a/furniture-3d-system/src/lib/non-integrated-bom-steel-materials.ts +++ b/furniture-3d-system/src/lib/non-integrated-bom-steel-materials.ts @@ -3,8 +3,9 @@ import { buildSteelFrameSearchAliases, calculateSteelFrameTotalWeight, calculateSteelFrameUnitPrice, + createSteelFrameTemplateState, normalizeSteelFrameText, - readSteelFrameLibraryState, + type SteelFrameLibraryState, type SteelFrameLibraryItem, } from '@/lib/steel-frame-library' @@ -44,9 +45,9 @@ function buildCandidateSearchTexts(row: Pick((item) => { const displayName = buildSteelFrameMatchLabel(item) diff --git a/furniture-3d-system/src/lib/open-product/__tests__/platform-view.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/platform-view.test.ts index eef90eaa..629ba916 100644 --- a/furniture-3d-system/src/lib/open-product/__tests__/platform-view.test.ts +++ b/furniture-3d-system/src/lib/open-product/__tests__/platform-view.test.ts @@ -5,9 +5,37 @@ import { readPreviewMaterialAssignments, readPreviewNodes, readPreviewWorldNodes, + readPreSkuTextField, } from '../platform-view' describe('platform-view helpers', () => { + it('准 SKU 文本读取应优先使用结构化 basicInfo', () => { + const preSku = { + basicInfo: { + model: 'DBZ-SPJK001', + formalSkuCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + specName: '1200 柚木色', + }, + displaySummary: { + model: 'LEGACY-MODEL', + skuCode: 'LEGACY-SKU', + barcode: 'LEGACY-BARCODE', + specName: 'LEGACY-SPEC', + seriesName: '单板桌', + }, + familySelection: { + skuCode: 'FAMILY-SKU', + }, + } + + expect(readPreSkuTextField(preSku, 'model')).toBe('DBZ-SPJK001') + expect(readPreSkuTextField(preSku, 'skuCode')).toBe('DBZ-SPJ00001') + expect(readPreSkuTextField(preSku, 'barcode')).toBe('DBZ-SPJ00001N05V01') + expect(readPreSkuTextField(preSku, 'specName')).toBe('1200 柚木色') + expect(readPreSkuTextField(preSku, 'seriesName')).toBe('单板桌') + }) + it('应只保留结构完整的预览节点', () => { const nodes = readPreviewNodes([ { diff --git a/furniture-3d-system/src/lib/open-product/asset-coding.ts b/furniture-3d-system/src/lib/open-product/asset-coding.ts index 47ea3f5d..b07aa36d 100644 --- a/furniture-3d-system/src/lib/open-product/asset-coding.ts +++ b/furniture-3d-system/src/lib/open-product/asset-coding.ts @@ -12,6 +12,10 @@ function trimUpper(value: string | null | undefined): string { return value?.trim().toUpperCase() ?? '' } +export function normalizeCoreIdentity(value: string | null | undefined): string { + return trimUpper(value) +} + export function normalizeSeriesCode(value: string | null | undefined): string { return trimUpper(value).replace(/[^A-Z0-9]/g, '') } diff --git a/furniture-3d-system/src/lib/open-product/platform-view.ts b/furniture-3d-system/src/lib/open-product/platform-view.ts index 3a01fa42..af97dc38 100644 --- a/furniture-3d-system/src/lib/open-product/platform-view.ts +++ b/furniture-3d-system/src/lib/open-product/platform-view.ts @@ -258,13 +258,53 @@ export interface PreviewHoleOverlayState { panelLabelByPanelId: Record } +type PreSkuBasicInfoTextSource = Partial<{ + model: string | null + formalSkuCode: string | null + barcode: string | null + size: string | null + color: string | null + material: string | null + specName: string | null + versionNo: string | null + setCount: string | null + attributeCode: string | null + drawingId: string | null + installMethod: string | null +}> + +const preSkuBasicInfoFieldAlias: Record = { + skuCode: 'formalSkuCode', + draftSkuCode: 'formalSkuCode', + skuSequenceCode: 'formalSkuCode', + formalSkuCode: 'formalSkuCode', + model: 'model', + barcode: 'barcode', + size: 'size', + color: 'color', + material: 'material', + specName: 'specName', + versionNo: 'versionNo', + setCount: 'setCount', + attributeCode: 'attributeCode', + drawingId: 'drawingId', + installMethod: 'installMethod', +} + export function readPreSkuTextField( preSku: { + basicInfo?: PreSkuBasicInfoTextSource | null displaySummary?: Record | null familySelection?: Record | null }, key: string, ) { + const basicInfoKey = preSkuBasicInfoFieldAlias[key] + const basicInfoValue = basicInfoKey ? preSku.basicInfo?.[basicInfoKey] : null + if (typeof basicInfoValue === 'string' && basicInfoValue.trim().length > 0) { + return basicInfoValue.trim() + } + const displayValue = preSku.displaySummary?.[key] if (typeof displayValue === 'string' && displayValue.trim().length > 0) { return displayValue.trim() diff --git a/furniture-3d-system/src/lib/packaging-material-library.test.ts b/furniture-3d-system/src/lib/packaging-material-library.test.ts index 6535cee6..baf6ca51 100644 --- a/furniture-3d-system/src/lib/packaging-material-library.test.ts +++ b/furniture-3d-system/src/lib/packaging-material-library.test.ts @@ -1,6 +1,7 @@ import { applyNonIntegratedBomPackagingMaterialMetrics, buildNonIntegratedBomPackagingMaterialOptions, + decodePackagingMaterialReferenceLibraryRows, findNonIntegratedBomPackagingMaterialOption, type PackagingMaterialLibraryItem, } from '@/lib/packaging-material-library' @@ -109,4 +110,33 @@ describe('packaging-material-library helpers', () => { expect(row.unitPrice).toBe('13') expect(row.material).toBe('泡沫') }) + + it('decodes packaging material identity from structured row fields before stale payload mirrors', () => { + const items = decodePackagingMaterialReferenceLibraryRows([ + { + id: 'row-1', + itemKey: 'PKG-A0', + code: 'A0', + name: '结构化泡沫', + letter: 'A', + updatedAt: '2026-05-09T00:00:00.000Z', + isActive: true, + payload: { + id: 'OLD-ID', + code: 'OLD', + name: '旧泡沫', + letter: 'Z', + price: '12.5', + }, + }, + ]) + + expect(items[0]).toMatchObject({ + id: 'PKG-A0', + code: 'A0', + name: '结构化泡沫', + letter: 'A', + price: '12.5', + }) + }) }) diff --git a/furniture-3d-system/src/lib/packaging-material-library.ts b/furniture-3d-system/src/lib/packaging-material-library.ts index 5dfa8488..c68c98f9 100644 --- a/furniture-3d-system/src/lib/packaging-material-library.ts +++ b/furniture-3d-system/src/lib/packaging-material-library.ts @@ -1,7 +1,6 @@ -export const PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY = - 'formal-intake-packaging-material-library-items' export const PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT = 'formal-intake-packaging-material-library:changed' +export const PACKAGING_MATERIAL_REFERENCE_LIBRARY_TYPE = 'PACKAGING_MATERIAL' export type PackagingMaterialLibraryItem = { id: string @@ -39,6 +38,17 @@ type PackagingMaterialLikeRow = { partImageName?: string } +type PackagingMaterialReferenceLibraryRow = { + id: string + itemKey: string + code: string + name: string + letter: string + payload: unknown + updatedAt: string + isActive?: boolean | null +} + function normalizeCode(value?: string | null) { return (value ?? '').trim().toUpperCase() } @@ -47,41 +57,49 @@ function normalizeName(value?: string | null) { return (value ?? '').trim().toLowerCase() } -function readRawPackagingMaterialItems() { - if (typeof window === 'undefined') return [] - - try { - const rawValue = window.localStorage.getItem( - PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY, - ) - if (!rawValue) return [] - - const parsedValue = JSON.parse(rawValue) - if (!Array.isArray(parsedValue)) return [] - - return parsedValue - } catch { - return [] - } +function readPayloadRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record } -export function readPackagingMaterialLibraryItems() { - return readRawPackagingMaterialItems().filter( - (item): item is PackagingMaterialLibraryItem => - item && - typeof item.id === 'string' && - typeof item.letter === 'string' && - typeof item.code === 'string' && - typeof item.name === 'string' && - typeof item.price === 'string' && - typeof item.imageUrl === 'string' && - typeof item.imageName === 'string' && - typeof item.updatedAt === 'string', - ) +function readPayloadText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +export function decodePackagingMaterialReferenceLibraryRows( + rows: PackagingMaterialReferenceLibraryRow[], +) { + return rows + .filter((row) => row.isActive !== false) + .map((row) => { + const payload = readPayloadRecord(row.payload) + const itemPayload = readPayloadRecord(payload.item) + const source = Object.keys(itemPayload).length ? itemPayload : payload + const id = row.itemKey || row.id + const letter = row.letter.toUpperCase() + const code = row.code + const name = row.name + + if (!id || !letter || !code || !name) return null + + const item: PackagingMaterialLibraryItem = { + id, + letter, + code, + name, + price: readPayloadText(source.price), + imageUrl: readPayloadText(source.imageUrl), + imageName: readPayloadText(source.imageName), + updatedAt: readPayloadText(source.updatedAt) || row.updatedAt, + } + + return item + }) + .filter((item): item is PackagingMaterialLibraryItem => item !== null) } export function buildNonIntegratedBomPackagingMaterialOptions( - items = readPackagingMaterialLibraryItems(), + items: PackagingMaterialLibraryItem[], ) { return items .map((item) => { diff --git a/furniture-3d-system/src/lib/permissions.ts b/furniture-3d-system/src/lib/permissions.ts index f4a733db..966d44f8 100644 --- a/furniture-3d-system/src/lib/permissions.ts +++ b/furniture-3d-system/src/lib/permissions.ts @@ -95,10 +95,12 @@ export function normalizeCollaboratorUsers(raw: unknown): CollaboratorUser[] { .map((item) => { const userId = typeof item.userId === 'string' ? item.userId.trim() : '' if (!userId) return null + const role = typeof item.role === 'string' ? item.role.trim().toLowerCase() : '' + if (role !== 'editor' && role !== 'viewer') return null return { userId, - role: item.role === 'viewer' ? 'viewer' : 'editor', + role, } satisfies CollaboratorUser }) .filter((item): item is CollaboratorUser => item !== null) diff --git a/furniture-3d-system/src/lib/planning/parsers.ts b/furniture-3d-system/src/lib/planning/parsers.ts index c464476a..82dbb968 100644 --- a/furniture-3d-system/src/lib/planning/parsers.ts +++ b/furniture-3d-system/src/lib/planning/parsers.ts @@ -14,6 +14,20 @@ function readString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null } +function readStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + + const normalized = Array.from( + new Set( + value + .map((item) => readString(item)) + .filter((item): item is string => Boolean(item)), + ), + ) + + return normalized.length > 0 ? normalized : undefined +} + function readVector3(value: unknown, fallback: [number, number, number]): [number, number, number] { if (!Array.isArray(value) || value.length !== 3) return fallback const parsed = value.map((item) => @@ -70,12 +84,8 @@ function parseSlotDefinition(value: unknown): PlanningSlotDefinition | null { const placementMode = placement.mode const source = isRecord(value.source) ? { - categoryIds: Array.isArray(value.source.categoryIds) - ? value.source.categoryIds.filter((item): item is string => typeof item === 'string') - : undefined, - assetIds: Array.isArray(value.source.assetIds) - ? value.source.assetIds.filter((item): item is string => typeof item === 'string') - : undefined, + categoryIds: readStringArray(value.source.categoryIds), + assetIds: readStringArray(value.source.assetIds), } : undefined @@ -159,12 +169,15 @@ export function parsePlanningTemplateDefinition(value: unknown): PlanningTemplat export function parsePlanningSlotSelections(value: unknown): PlanningSlotSelections { if (!isRecord(value)) return {} - return Object.fromEntries( - Object.entries(value).map(([slotKey, rawIds]) => [ - slotKey, - Array.isArray(rawIds) - ? rawIds.filter((item): item is string => typeof item === 'string') - : [], - ]), - ) + const entries: Array<[string, string[]]> = [] + + Object.entries(value).forEach(([rawSlotKey, rawIds]) => { + const slotKey = readString(rawSlotKey) + if (!slotKey) return + + const assetIds = readStringArray(rawIds) ?? [] + entries.push([slotKey, assetIds]) + }) + + return Object.fromEntries(entries) } diff --git a/furniture-3d-system/src/lib/sku-cad-data.ts b/furniture-3d-system/src/lib/sku-cad-data.ts index 0f348de0..8bea9838 100644 --- a/furniture-3d-system/src/lib/sku-cad-data.ts +++ b/furniture-3d-system/src/lib/sku-cad-data.ts @@ -843,6 +843,36 @@ export function readSkuCadDataFromAttributes(attributes: unknown): PersistedSkuC }) } +function readSkuCadCompatibilityAttributes(attributes: unknown): Record { + return attributes && typeof attributes === 'object' && !Array.isArray(attributes) + ? (attributes as Record) + : {} +} + +export function hasSkuCadCompatibilityData(attributes: unknown): boolean { + return Object.prototype.hasOwnProperty.call( + readSkuCadCompatibilityAttributes(attributes), + SKU_CAD_DATA_ATTRIBUTE_KEY, + ) +} + +export function readSkuCadExcludedHolesJson(attributes: unknown): string | null { + const value = readSkuCadCompatibilityAttributes(attributes)._cadExcludedHoles + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +export function readSkuCadExcludedHolesCount(attributes: unknown): number { + const value = readSkuCadCompatibilityAttributes(attributes)._cadExcludedHolesCount + if (typeof value === 'string') { + const numericValue = Number(value) + return Number.isFinite(numericValue) ? numericValue : 0 + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0 + } + return 0 +} + export function filterDisplaySkuAttributes(attributes: Record | null): Record | null { if (!attributes) { return null diff --git a/furniture-3d-system/src/lib/steel-frame-library.test.ts b/furniture-3d-system/src/lib/steel-frame-library.test.ts index 910e9ae4..db6e39f0 100644 --- a/furniture-3d-system/src/lib/steel-frame-library.test.ts +++ b/furniture-3d-system/src/lib/steel-frame-library.test.ts @@ -1,12 +1,15 @@ import { buildSteelFrameLibraryItems, + buildSteelFrameReferenceItemKey, calculateSteelFramePieceCount, calculateSteelFrameUnitPrice, calculateSteelFrameWeightPerBar, + createSteelFrameTemplateState, createSteelFrameLibraryItem, - persistSteelFrameLibraryState, - readSteelFrameLibraryState, - STEEL_FRAME_LIBRARY_STORAGE_KEY, + decodeSteelFrameReferenceLibraryRows, + STEEL_FRAME_REFERENCE_CONFIG_CATEGORY, + STEEL_FRAME_REFERENCE_ITEM_CATEGORY, + STEEL_FRAME_REFERENCE_PRICE_KEY, } from '@/lib/steel-frame-library' describe('steel-frame-library', () => { @@ -48,14 +51,14 @@ describe('steel-frame-library', () => { expect(calculateSteelFrameUnitPrice(weight, '4200')).toBe('0.58414608') }) - it('returns an empty library when no custom steel frame spec exists', () => { - const state = readSteelFrameLibraryState() + it('returns an empty template state when the server library has no steel frame spec', () => { + const state = createSteelFrameTemplateState() expect(state.referenceSteelPrice).toBe('4200') expect(state.items).toHaveLength(0) }) - it('persists only saved custom steel frame items and rebuilds them under the current steel price', () => { + it('decodes server rows and rebuilds saved steel frame items under the current steel price', () => { const customItem = createSteelFrameLibraryItem( { name: '扁管', @@ -67,10 +70,30 @@ describe('steel-frame-library', () => { '4500', ) - persistSteelFrameLibraryState('4500', [customItem]) + const state = decodeSteelFrameReferenceLibraryRows([ + { + itemKey: STEEL_FRAME_REFERENCE_PRICE_KEY, + category: STEEL_FRAME_REFERENCE_CONFIG_CATEGORY, + payload: { + kind: STEEL_FRAME_REFERENCE_CONFIG_CATEGORY, + referenceSteelPrice: '4500', + }, + updatedAt: '2026-05-30T00:00:00.000Z', + isActive: true, + }, + { + itemKey: buildSteelFrameReferenceItemKey(customItem), + category: STEEL_FRAME_REFERENCE_ITEM_CATEGORY, + payload: { + kind: STEEL_FRAME_REFERENCE_ITEM_CATEGORY, + item: customItem, + }, + updatedAt: '2026-05-30T00:00:00.000Z', + isActive: true, + }, + ]) - const state = readSteelFrameLibraryState() - const matched = state.items.find((item) => item.id === customItem.id) + const matched = state.items.find((item) => item.id === buildSteelFrameReferenceItemKey(customItem)) expect(state.referenceSteelPrice).toBe('4500') expect(state.items).toHaveLength(1) @@ -79,7 +102,7 @@ describe('steel-frame-library', () => { expect(matched?.unitPrice).toBe('4.431168') }) - it('keeps backward compatibility with the old customItems storage key', () => { + it('keeps backward compatibility with flat legacy payload rows from the server bridge', () => { const legacyItem = createSteelFrameLibraryItem( { name: '方管', @@ -91,15 +114,24 @@ describe('steel-frame-library', () => { '4200', ) - window.localStorage.setItem( - STEEL_FRAME_LIBRARY_STORAGE_KEY, - JSON.stringify({ - referenceSteelPrice: '4800', - customItems: [legacyItem], - }), - ) - - const state = readSteelFrameLibraryState() + const state = decodeSteelFrameReferenceLibraryRows([ + { + itemKey: STEEL_FRAME_REFERENCE_PRICE_KEY, + category: STEEL_FRAME_REFERENCE_CONFIG_CATEGORY, + payload: { + value: '4800', + }, + updatedAt: '2026-05-30T00:00:00.000Z', + isActive: true, + }, + { + itemKey: 'item:legacy-steel-frame-id', + category: STEEL_FRAME_REFERENCE_ITEM_CATEGORY, + payload: legacyItem, + updatedAt: '2026-05-30T00:00:00.000Z', + isActive: true, + }, + ]) expect(state.referenceSteelPrice).toBe('4800') expect(state.items).toHaveLength(1) @@ -123,4 +155,34 @@ describe('steel-frame-library', () => { expect(rebuiltItems).toHaveLength(1) expect(rebuiltItems[0]?.unitPrice).toBe('1.76486688') }) + + it('builds a reference-library identity from steel frame specs instead of UI id', () => { + const first = createSteelFrameLibraryItem( + { + name: '扁管', + length: '60', + width: '20', + wallThickness: '0.8', + remark: '', + }, + '4500', + { id: 'temporary-ui-id-a' }, + ) + const second = createSteelFrameLibraryItem( + { + name: '扁管', + length: '60', + width: '20', + wallThickness: '0.8', + remark: '', + }, + '4500', + { id: 'temporary-ui-id-b' }, + ) + + expect(buildSteelFrameReferenceItemKey(first)).toBe( + buildSteelFrameReferenceItemKey(second), + ) + expect(buildSteelFrameReferenceItemKey(first)).toBe('ITEM:扁管:60:20:0.8') + }) }) diff --git a/furniture-3d-system/src/lib/steel-frame-library.ts b/furniture-3d-system/src/lib/steel-frame-library.ts index 992fb8f1..8bfac340 100644 --- a/furniture-3d-system/src/lib/steel-frame-library.ts +++ b/furniture-3d-system/src/lib/steel-frame-library.ts @@ -1,4 +1,9 @@ -export const STEEL_FRAME_LIBRARY_STORAGE_KEY = 'formal-intake-steel-frame-library' +export const STEEL_FRAME_LIBRARY_CHANGED_EVENT = + 'formal-intake-steel-frame-library:changed' +export const STEEL_FRAME_REFERENCE_LIBRARY_TYPE = 'STEEL_FRAME' +export const STEEL_FRAME_REFERENCE_CONFIG_CATEGORY = 'CONFIG' +export const STEEL_FRAME_REFERENCE_ITEM_CATEGORY = 'ITEM' +export const STEEL_FRAME_REFERENCE_PRICE_KEY = 'reference-steel-price' export const STEEL_FRAME_TEMPLATE_FIELDS = ['名称', '长', '宽', '壁厚', '重量', '支数', '单价'] as const @@ -24,10 +29,12 @@ export type SteelFrameLibraryState = { items: SteelFrameLibraryItem[] } -type SteelFrameLibraryPersistedState = { - referenceSteelPrice: string - items?: SteelFrameLibraryItem[] - customItems?: SteelFrameLibraryItem[] +type SteelFrameReferenceLibraryRow = { + itemKey: string + category: string + payload: unknown + updatedAt: string + isActive?: boolean | null } export const STEEL_FRAME_TEMPLATE_ITEMS: SteelFrameLibraryItem[] = [] @@ -125,6 +132,26 @@ export function createSteelFrameLibraryItemId() { return `steel-custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } +function normalizeReferenceKeyPart(value?: string | null) { + const normalized = (value ?? '').trim().toUpperCase() + return normalized + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'UNKNOWN' +} + +export function buildSteelFrameReferenceItemKey( + item: Pick, +) { + return [ + 'ITEM', + normalizeReferenceKeyPart(item.name), + normalizeReferenceKeyPart(item.length), + normalizeReferenceKeyPart(item.width || 'NO-WIDTH'), + normalizeReferenceKeyPart(item.wallThickness), + ].join(':') +} + export function calculateSteelFrameWeightPerBar( draft: Pick, ) { @@ -192,38 +219,6 @@ export function createSteelFrameLibraryItem( } } -function readPersistedSteelFrameLibraryValue(): Partial { - if (typeof window === 'undefined') { - return {} - } - - try { - const rawValue = window.localStorage.getItem(STEEL_FRAME_LIBRARY_STORAGE_KEY) - if (!rawValue) { - return {} - } - - const parsedValue = JSON.parse(rawValue) - return typeof parsedValue === 'object' && parsedValue ? parsedValue : {} - } catch { - return {} - } -} - -function resolvePersistedSteelFrameItems( - persistedValue: Partial, -) { - if (Array.isArray(persistedValue.items)) { - return persistedValue.items - } - - if (Array.isArray(persistedValue.customItems)) { - return persistedValue.customItems - } - - return [] -} - export function buildSteelFrameLibraryItems( referenceSteelPrice: string, items: SteelFrameLibraryItem[], @@ -257,47 +252,58 @@ export function createSteelFrameTemplateState(): SteelFrameLibraryState { } } -export function readSteelFrameLibraryState(): SteelFrameLibraryState { - if (typeof window === 'undefined') { - return createSteelFrameTemplateState() - } +function readPayloadRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} - const persistedValue = readPersistedSteelFrameLibraryValue() - const referenceSteelPrice = - typeof persistedValue.referenceSteelPrice === 'string' && persistedValue.referenceSteelPrice.trim() - ? persistedValue.referenceSteelPrice.trim() - : STEEL_FRAME_TEMPLATE_REFERENCE_PRICE - const items = resolvePersistedSteelFrameItems(persistedValue) +function readPayloadText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +export function decodeSteelFrameReferenceLibraryRows( + rows: SteelFrameReferenceLibraryRow[], +): SteelFrameLibraryState { + let referenceSteelPrice = '' + const items: SteelFrameLibraryItem[] = [] + + rows.forEach((row) => { + if (row.isActive === false) return + + const payload = readPayloadRecord(row.payload) + const kind = readPayloadText(payload.kind) || row.category + + if (kind === STEEL_FRAME_REFERENCE_CONFIG_CATEGORY) { + referenceSteelPrice = + readPayloadText(payload.referenceSteelPrice) || + readPayloadText(payload.value) || + referenceSteelPrice + return + } + + if (kind === STEEL_FRAME_REFERENCE_ITEM_CATEGORY) { + const itemPayload = readPayloadRecord(payload.item) + const source = Object.keys(itemPayload).length ? itemPayload : payload + const item = createSteelFrameLibraryItem(source, STEEL_FRAME_TEMPLATE_REFERENCE_PRICE, { + id: row.itemKey.replace(/^item:/, ''), + updatedAt: readPayloadText(source.updatedAt) || row.updatedAt, + }) + + if (item.name && item.length && item.wallThickness && item.weight) { + items.push(item) + } + } + }) + + const resolvedReferenceSteelPrice = + referenceSteelPrice || STEEL_FRAME_TEMPLATE_REFERENCE_PRICE return { - referenceSteelPrice, - items: buildSteelFrameLibraryItems(referenceSteelPrice, items), + referenceSteelPrice: resolvedReferenceSteelPrice, + items: buildSteelFrameLibraryItems(resolvedReferenceSteelPrice, items), } } -export function persistSteelFrameLibraryState( - referenceSteelPrice: string, - items: SteelFrameLibraryItem[], -) { - if (typeof window === 'undefined') return - - const normalizedReferenceSteelPrice = referenceSteelPrice.trim() || STEEL_FRAME_TEMPLATE_REFERENCE_PRICE - const resolvedItems = buildSteelFrameLibraryItems(normalizedReferenceSteelPrice, items) - - window.localStorage.setItem( - STEEL_FRAME_LIBRARY_STORAGE_KEY, - JSON.stringify({ - referenceSteelPrice: normalizedReferenceSteelPrice, - items: resolvedItems, - } satisfies SteelFrameLibraryPersistedState), - ) -} - -export function persistSteelFrameLibraryReferencePrice(referenceSteelPrice: string) { - const persistedValue = readPersistedSteelFrameLibraryValue() - persistSteelFrameLibraryState(referenceSteelPrice, resolvePersistedSteelFrameItems(persistedValue)) -} - export function buildSteelFrameSizeLabel(item: Pick) { const normalizedLength = item.length.trim() const normalizedWidth = item.width.trim() diff --git a/furniture-3d-system/src/server/dto/product.dto.ts b/furniture-3d-system/src/server/dto/product.dto.ts index 3793f433..e92bf2c1 100644 --- a/furniture-3d-system/src/server/dto/product.dto.ts +++ b/furniture-3d-system/src/server/dto/product.dto.ts @@ -6,7 +6,8 @@ * @created 2026-01-30 */ -import type { Product, ProductSKU, User } from '@prisma/client' +import type { Product, ProductSKU, ProductSkuAssetProfile, User } from '@prisma/client' +import { buildSkuDisplayTags, toLegacySkuDisplayAttributes } from '@/lib/sku-display-tags' // ============ 基础 DTO ============ @@ -19,13 +20,35 @@ export interface UserBriefDTO { avatar?: string | null } +export interface ProductSkuAssetProfileDTO { + primarySourceAssetVariantId: string | null + sourceAssetVariantIds: string[] + sourceAssetFamilyId: string | null + sourceAssetSeriesId: string | null + model: string | null + modelName: string | null + specCode: string | null + specName: string | null + barcode: string | null + size: unknown + color: string | null + material: string | null + imageUrl: string | null + sourceStatus: string + syncedAt: string + lockedFields: string[] +} + /** * SKU 简要信息 */ export interface SKUBriefDTO { id: string skuCode: string - attributes: Record + name?: string | null + legacyAttributes: Record | null + assetProfile: ProductSkuAssetProfileDTO | null + displayTags: string[] price: number stock: number isActive: boolean @@ -39,7 +62,10 @@ export interface SKUDTO { id: string productId: string skuCode: string - attributes: Record + name?: string | null + legacyAttributes: Record | null + assetProfile: ProductSkuAssetProfileDTO | null + displayTags: string[] price: number costPrice?: number | null stock: number @@ -160,14 +186,47 @@ export function toUserBriefDTO(user: Pick): User } } +export function toProductSkuAssetProfileDTO( + profile: ProductSkuAssetProfile | null | undefined, +): ProductSkuAssetProfileDTO | null { + if (!profile) return null + + return { + primarySourceAssetVariantId: profile.primarySourceAssetVariantId, + sourceAssetVariantIds: profile.sourceAssetVariantIds, + sourceAssetFamilyId: profile.sourceAssetFamilyId, + sourceAssetSeriesId: profile.sourceAssetSeriesId, + model: profile.model, + modelName: profile.modelName, + specCode: profile.specCode, + specName: profile.specName, + barcode: profile.barcode, + size: profile.size, + color: profile.color, + material: profile.material, + imageUrl: profile.imageUrl, + sourceStatus: profile.sourceStatus, + syncedAt: profile.syncedAt.toISOString(), + lockedFields: profile.lockedFields, + } +} + /** * 将 ProductSKU 实体转换为 SKUBriefDTO */ -export function toSKUBriefDTO(sku: ProductSKU): SKUBriefDTO { +export function toSKUBriefDTO( + sku: ProductSKU & { assetProfile?: ProductSkuAssetProfile | null }, +): SKUBriefDTO { + const legacyAttributes = toLegacySkuDisplayAttributes(sku.attributes) + const assetProfile = toProductSkuAssetProfileDTO(sku.assetProfile) + return { id: sku.id, skuCode: sku.skuCode, - attributes: sku.attributes as Record, + name: sku.name, + legacyAttributes, + assetProfile, + displayTags: buildSkuDisplayTags({ assetProfile, legacyAttributes }), price: Number(sku.price), stock: sku.stock, isActive: sku.isActive, @@ -179,13 +238,22 @@ export function toSKUBriefDTO(sku: ProductSKU): SKUBriefDTO { * 将 ProductSKU 实体转换为 SKUDTO */ export function toSKUDTO( - sku: ProductSKU & { owner: Pick } + sku: ProductSKU & { + owner: Pick + assetProfile?: ProductSkuAssetProfile | null + } ): SKUDTO { + const legacyAttributes = toLegacySkuDisplayAttributes(sku.attributes) + const assetProfile = toProductSkuAssetProfileDTO(sku.assetProfile) + return { id: sku.id, productId: sku.productId, skuCode: sku.skuCode, - attributes: sku.attributes as Record, + name: sku.name, + legacyAttributes, + assetProfile, + displayTags: buildSkuDisplayTags({ assetProfile, legacyAttributes }), price: Number(sku.price), costPrice: sku.costPrice ? Number(sku.costPrice) : null, stock: sku.stock, diff --git a/furniture-3d-system/src/server/dto/sku.dto.ts b/furniture-3d-system/src/server/dto/sku.dto.ts index fac08b87..afe21b95 100644 --- a/furniture-3d-system/src/server/dto/sku.dto.ts +++ b/furniture-3d-system/src/server/dto/sku.dto.ts @@ -4,8 +4,14 @@ * @created 2026-01-30 */ -import type { ProductSKU, User, Product, ProductCategory } from '@prisma/client' -import { toUserBriefDTO, type UserBriefDTO } from './product.dto' +import type { ProductSKU, ProductSkuAssetProfile, User, Product, ProductCategory } from '@prisma/client' +import { + toProductSkuAssetProfileDTO, + toUserBriefDTO, + type ProductSkuAssetProfileDTO, + type UserBriefDTO, +} from './product.dto' +import { buildSkuDisplayTags, toLegacySkuDisplayAttributes } from '@/lib/sku-display-tags' // ============ DTO 定义 ============ @@ -16,9 +22,12 @@ export interface SKUListItemDTO { id: string productId: string skuCode: string + name?: string | null productName: string productModelNumber: string - attributes: Record + legacyAttributes: Record | null + assetProfile: ProductSkuAssetProfileDTO | null + displayTags: string[] price: number stock: number isActive: boolean @@ -35,7 +44,7 @@ export interface DeletedSKUDTO { skuCode: string deletedAt: string price: number - attributes: Record | null + legacyAttributes: Record | null owner: UserBriefDTO product: { id: string @@ -51,7 +60,10 @@ export interface SKUDetailDTO { id: string productId: string skuCode: string - attributes: Record + name?: string | null + legacyAttributes: Record | null + assetProfile: ProductSkuAssetProfileDTO | null + displayTags: string[] price: number costPrice?: number | null stock: number @@ -83,6 +95,7 @@ export interface SKUDetailDTO { export interface CreateSKUInput { productId: string skuCode: string + name?: string attributes: Record price: number costPrice?: number @@ -97,6 +110,7 @@ export interface CreateSKUInput { */ export interface UpdateSKUInput { skuCode?: string + name?: string attributes?: Record price?: number costPrice?: number | null @@ -123,15 +137,22 @@ export function toSKUListItemDTO( sku: ProductSKU & { owner: Pick product: Pick + assetProfile?: ProductSkuAssetProfile | null } ): SKUListItemDTO { + const legacyAttributes = toLegacySkuDisplayAttributes(sku.attributes) + const assetProfile = toProductSkuAssetProfileDTO(sku.assetProfile) + return { id: sku.id, productId: sku.productId, skuCode: sku.skuCode, + name: sku.name, productName: sku.product.name, productModelNumber: sku.product.modelNumber, - attributes: sku.attributes as Record, + legacyAttributes, + assetProfile, + displayTags: buildSkuDisplayTags({ assetProfile, legacyAttributes }), price: Number(sku.price), stock: sku.stock, isActive: sku.isActive, @@ -147,16 +168,23 @@ export function toSKUListItemDTO( export function toSKUDetailDTO( sku: ProductSKU & { owner: Pick + assetProfile?: ProductSkuAssetProfile | null product: Pick & { category: Pick | null } } ): SKUDetailDTO { + const legacyAttributes = toLegacySkuDisplayAttributes(sku.attributes) + const assetProfile = toProductSkuAssetProfileDTO(sku.assetProfile) + return { id: sku.id, productId: sku.productId, skuCode: sku.skuCode, - attributes: sku.attributes as Record, + name: sku.name, + legacyAttributes, + assetProfile, + displayTags: buildSkuDisplayTags({ assetProfile, legacyAttributes }), price: Number(sku.price), costPrice: sku.costPrice ? Number(sku.costPrice) : null, stock: sku.stock, diff --git a/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts index c806ea5c..23004ade 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts @@ -1,7 +1,14 @@ jest.mock('@/lib/prisma', () => ({ prisma: { + $transaction: jest.fn(), intakeNonIntegratedProduct: { findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + intakeNonIntegratedSkuItem: { + createMany: jest.fn(), + deleteMany: jest.fn(), }, }, })) @@ -10,14 +17,24 @@ import { prisma } from '@/lib/prisma' import { IntakeNonIntegratedProductRepositoryClass } from '../intake-non-integrated-product.repository' const mockPrisma = prisma as unknown as { + $transaction: jest.Mock intakeNonIntegratedProduct: { findMany: jest.Mock + create: jest.Mock + update: jest.Mock + } + intakeNonIntegratedSkuItem: { + createMany: jest.Mock + deleteMany: jest.Mock } } describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { beforeEach(() => { jest.clearAllMocks() + mockPrisma.$transaction.mockImplementation(async (callback: (tx: typeof mockPrisma) => unknown) => + callback(mockPrisma), + ) }) it('reuses soft-deleted records when old brand names match normalized brands', async () => { @@ -80,4 +97,114 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { expect(result).toBeNull() }) + + it('creates structured SKU rows after creating the product record', async () => { + const repository = new IntakeNonIntegratedProductRepositoryClass() + mockPrisma.intakeNonIntegratedProduct.create.mockResolvedValue({ + id: 'non-integrated-1', + spu: 'DNZ0001', + }) + mockPrisma.intakeNonIntegratedSkuItem.createMany.mockResolvedValue({ count: 1 }) + + await repository.create({ + id: 'non-integrated-1', + spu: 'DNZ0001', + spuBase: 'DNZ0001', + brand: 'Base', + designer: 'Designer', + operator: 'Operator', + purchaser: 'Purchaser', + payload: {}, + visibility: 'DEPARTMENT', + createdBy: 'user-1', + updatedBy: 'user-1', + skuItems: { + createMany: { + data: [ + { + id: 'non-integrated-1:sku-1', + skuItemId: 'sku-1', + sortOrder: 1, + skuCode: 'SKU-001', + dataQualityStatus: 'READY', + missingRequiredFields: [], + }, + ], + }, + }, + } as never) + + expect(mockPrisma.intakeNonIntegratedProduct.create).toHaveBeenCalledWith({ + data: expect.not.objectContaining({ + skuItems: expect.anything(), + }), + }) + expect(mockPrisma.intakeNonIntegratedSkuItem.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + id: 'non-integrated-1:sku-1', + productId: 'non-integrated-1', + skuCode: 'SKU-001', + }), + ], + }) + }) + + it('refreshes structured SKU rows in an explicit delete-then-create transaction', async () => { + const repository = new IntakeNonIntegratedProductRepositoryClass() + const callOrder: string[] = [] + mockPrisma.intakeNonIntegratedProduct.update.mockImplementation(async () => { + callOrder.push('product.update') + return { + id: 'non-integrated-1', + spu: 'DNZ0001', + } + }) + mockPrisma.intakeNonIntegratedSkuItem.deleteMany.mockImplementation(async () => { + callOrder.push('sku.deleteMany') + return { count: 1 } + }) + mockPrisma.intakeNonIntegratedSkuItem.createMany.mockImplementation(async () => { + callOrder.push('sku.createMany') + return { count: 1 } + }) + + await repository.update('non-integrated-1', { + payload: {}, + skuItems: { + deleteMany: {}, + createMany: { + data: [ + { + id: 'non-integrated-1:sku-1', + skuItemId: 'sku-1', + sortOrder: 1, + skuCode: 'SKU-001', + dataQualityStatus: 'READY', + missingRequiredFields: [], + }, + ], + }, + }, + } as never) + + expect(callOrder).toEqual(['product.update', 'sku.deleteMany', 'sku.createMany']) + expect(mockPrisma.intakeNonIntegratedProduct.update).toHaveBeenCalledWith({ + where: { id: 'non-integrated-1' }, + data: expect.not.objectContaining({ + skuItems: expect.anything(), + }), + }) + expect(mockPrisma.intakeNonIntegratedSkuItem.deleteMany).toHaveBeenCalledWith({ + where: { productId: 'non-integrated-1' }, + }) + expect(mockPrisma.intakeNonIntegratedSkuItem.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + productId: 'non-integrated-1', + skuCode: 'SKU-001', + }), + ], + }) + }) }) diff --git a/furniture-3d-system/src/server/repositories/__tests__/product.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/product.repository.test.ts index eff4cabf..bbca0b0f 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/product.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/product.repository.test.ts @@ -1,31 +1,26 @@ -jest.mock('@/lib/prisma', () => ({ - prisma: { - product: { - findMany: jest.fn(), - count: jest.fn(), - }, - }, -})) +import { ProductRepositoryClass } from '../product.repository' -import { prisma } from '@/lib/prisma' -import { productRepository } from '../product.repository' - -const mockPrisma = prisma as unknown as { - product: { - findMany: jest.Mock - count: jest.Mock +function createRepositoryWithTransaction(tx: Record) { + const repository = new ProductRepositoryClass() + ;(repository as unknown as { prisma: { $transaction: jest.Mock } }).prisma = { + $transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } + return repository } -describe('productRepository formalizedOnly', () => { - beforeEach(() => { - jest.clearAllMocks() - mockPrisma.product.findMany.mockResolvedValue([]) - mockPrisma.product.count.mockResolvedValue(0) - }) - +describe('ProductRepositoryClass master data identity guards', () => { it('只筛选来自准 SKU 正式入库且未删除的产品 SKU', async () => { - await productRepository.findManyWithRelations({ + const repository = new ProductRepositoryClass() + const findMany = jest.fn().mockResolvedValue([]) + const count = jest.fn().mockResolvedValue(0) + ;(repository as unknown as { prisma: { product: { findMany: jest.Mock; count: jest.Mock } } }).prisma = { + product: { + findMany, + count, + }, + } + + await repository.findManyWithRelations({ pagination: { page: 1, limit: 20 }, filter: { createdBy: 'user-1', @@ -33,7 +28,7 @@ describe('productRepository formalizedOnly', () => { }, }) - expect(mockPrisma.product.findMany).toHaveBeenCalledWith( + expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { deletedAt: null, @@ -50,7 +45,7 @@ describe('productRepository formalizedOnly', () => { take: 20, }), ) - expect(mockPrisma.product.count).toHaveBeenCalledWith({ + expect(count).toHaveBeenCalledWith({ where: { deletedAt: null, createdBy: 'user-1', @@ -63,4 +58,338 @@ describe('productRepository formalizedOnly', () => { }, }) }) + + it('恢复产品 SKU 时遇到原始编码冲突应阻断,不自动追加后缀', async () => { + const skuUpdate = jest.fn() + const tx = { + product: { + update: jest.fn().mockResolvedValue({ id: 'product-1' }), + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sku-1', + skuCode: 'SKU-001__deleted__20260531_sku-1', + attributes: { + _originalSkuCode: ' sku-001 ', + _deletedCodeAt: '2026-05-31T00:00:00.000Z', + _deletedByProductId: 'product-1', + }, + originalSkuCode: null, + deletedByProductId: 'product-1', + }, + ]), + count: jest.fn().mockResolvedValue(1), + update: skuUpdate, + }, + } + const repository = createRepositoryWithTransaction(tx) + + await expect(repository.restoreWithSkus('product-1')).rejects.toThrow( + '原始 SKU 编码「SKU-001」已被占用', + ) + expect(skuUpdate).not.toHaveBeenCalled() + }) + + it('级联软删除产品时同步释放 SKU 资产 profile 条码唯一池', async () => { + const deletedAt = new Date('2026-05-31T12:00:00.000Z') + const skuUpdate = jest.fn() + const profileUpdateMany = jest.fn() + const tx = { + product: { + update: jest.fn().mockResolvedValue({ id: 'product-1' }), + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sku-1', + skuCode: 'SKU-001', + attributes: {}, + originalSkuCode: null, + }, + ]), + update: skuUpdate, + }, + productSkuAssetProfile: { + updateMany: profileUpdateMany, + }, + } + const repository = createRepositoryWithTransaction(tx) + + await repository.softDeleteWithCascade('product-1', deletedAt) + + expect(skuUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sku-1' }, + data: expect.objectContaining({ + deletedAt, + skuCode: expect.stringContaining('__deleted__'), + originalSkuCode: 'SKU-001', + deletedCodeAt: deletedAt, + deletedByProductId: 'product-1', + productDeletedAt: deletedAt, + attributes: {}, + }), + }), + ) + expect(profileUpdateMany).toHaveBeenCalledWith({ + where: { skuId: 'sku-1' }, + data: { skuDeletedAt: deletedAt }, + }) + }) + + it('恢复产品 SKU 时遇到资产 profile 条码冲突应阻断', async () => { + const skuUpdate = jest.fn() + const tx = { + product: { + update: jest.fn().mockResolvedValue({ id: 'product-1' }), + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sku-1', + skuCode: 'SKU-001__deleted__20260531_sku-1', + attributes: { + _originalSkuCode: 'SKU-001', + _deletedByProductId: 'product-1', + }, + originalSkuCode: 'SKU-001', + deletedByProductId: 'product-1', + }, + ]), + count: jest.fn().mockResolvedValue(0), + update: skuUpdate, + }, + productSkuAssetProfile: { + findUnique: jest.fn().mockResolvedValue({ + barcode: ' bc-001 ', + sourceStatus: 'ACTIVE', + }), + findFirst: jest.fn().mockResolvedValue({ skuId: 'sku-2' }), + updateMany: jest.fn(), + }, + } + const repository = createRepositoryWithTransaction(tx) + + await expect(repository.restoreWithSkus('product-1')).rejects.toThrow( + '条码「BC-001」已被占用', + ) + expect(tx.productSkuAssetProfile.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + barcode: { equals: 'BC-001', mode: 'insensitive' }, + }), + }), + ) + expect(skuUpdate).not.toHaveBeenCalled() + expect(tx.productSkuAssetProfile.updateMany).not.toHaveBeenCalled() + }) + + it('恢复产品时不会恢复原本单独删除的 SKU', async () => { + const skuUpdate = jest.fn() + const tx = { + product: { + update: jest.fn().mockResolvedValue({ id: 'product-1' }), + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sku-standalone-deleted', + skuCode: 'SKU-002__deleted__20260530_sku-2', + attributes: { + _originalSkuCode: 'SKU-002', + _deletedCodeAt: '2026-05-30T00:00:00.000Z', + }, + originalSkuCode: 'SKU-002', + deletedByProductId: null, + }, + ]), + count: jest.fn(), + update: skuUpdate, + }, + productSkuAssetProfile: { + findUnique: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + }, + } + const repository = createRepositoryWithTransaction(tx) + + await expect(repository.restoreWithSkus('product-1')).resolves.toEqual({ + count: 0, + }) + expect(skuUpdate).not.toHaveBeenCalled() + expect(tx.productSkuAssetProfile.updateMany).not.toHaveBeenCalled() + }) + + it('恢复产品时不再读取旧 attributes 生命周期字段判断归属', async () => { + const skuUpdate = jest.fn() + const tx = { + product: { + update: jest.fn().mockResolvedValue({ id: 'product-1' }), + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sku-legacy-lifecycle', + skuCode: 'SKU-003__deleted__20260531_sku-3', + attributes: { + _originalSkuCode: 'SKU-003', + _deletedByProductId: 'product-1', + }, + originalSkuCode: 'SKU-003', + deletedByProductId: null, + }, + ]), + count: jest.fn(), + update: skuUpdate, + }, + productSkuAssetProfile: { + findUnique: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + }, + } + const repository = createRepositoryWithTransaction(tx) + + await expect(repository.restoreWithSkus('product-1')).resolves.toEqual({ + count: 0, + }) + expect(skuUpdate).not.toHaveBeenCalled() + expect(tx.productSkuAssetProfile.updateMany).not.toHaveBeenCalled() + }) + + it('CAD 导入创建默认 SKU 前会释放遗留删除态编码', async () => { + const deletedSkuUpdate = jest.fn() + const skuCreate = jest.fn().mockResolvedValue({ id: 'sku-created', skuCode: 'SPU-001-CAD' }) + const tx = { + product: { + create: jest.fn().mockResolvedValue({ id: 'product-1' }), + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sku-deleted-1', + skuCode: 'SPU-001-CAD', + attributes: {}, + deletedAt: new Date('2026-05-31T12:00:00.000Z'), + originalSkuCode: null, + deletedCodeAt: null, + }, + ]), + update: deletedSkuUpdate, + findFirst: jest.fn().mockResolvedValue(null), + create: skuCreate, + }, + panel: { + create: jest.fn(), + }, + hole: { + createMany: jest.fn(), + }, + } + const repository = createRepositoryWithTransaction(tx) + + await repository.createProductFromCAD({ + modelNumber: 'SPU-001', + name: '测试产品', + visibility: 'TEAM', + modelUrl: '/models/test.glb', + sourceFileName: 'test.dxf', + userId: 'user-1', + processingResult: { + success: true, + version: '2.2', + processingPath: 'direct', + panels: [], + holeGroups: [], + snapPatterns: [], + snapSurfaces: [], + mountPatterns: [], + hardware: [], + statistics: { + totalPanels: 0, + totalHoles: 0, + totalHardware: 0, + directMappedPanels: 0, + fallbackPanels: 0, + directMappedHardware: 0, + fallbackHardware: 0, + }, + warnings: [], + errors: [], + }, + }) + + expect(deletedSkuUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sku-deleted-1' }, + data: expect.objectContaining({ + skuCode: expect.stringContaining('SPU-001-CAD__deleted__'), + originalSkuCode: 'SPU-001-CAD', + deletedCodeAt: new Date('2026-05-31T12:00:00.000Z'), + attributes: {}, + }), + }), + ) + expect(skuCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + skuCode: 'SPU-001-CAD', + }), + }), + ) + }) + + it('CAD 导入默认 SKU 编码已存在时应阻断,不自动创建后缀编码', async () => { + const productCreate = jest.fn() + const skuCreate = jest.fn() + const tx = { + product: { + create: productCreate, + }, + productSKU: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue({ id: 'sku-existing' }), + update: jest.fn(), + create: skuCreate, + }, + } + const repository = createRepositoryWithTransaction(tx) + + await expect( + repository.createProductFromCAD({ + modelNumber: 'SPU-001', + name: '测试产品', + visibility: 'TEAM', + modelUrl: '/models/test.glb', + sourceFileName: 'test.dxf', + userId: 'user-1', + processingResult: { + success: true, + version: '2.2', + processingPath: 'direct', + panels: [], + holeGroups: [], + snapPatterns: [], + snapSurfaces: [], + mountPatterns: [], + hardware: [], + statistics: { + totalPanels: 0, + totalHoles: 0, + totalHardware: 0, + directMappedPanels: 0, + fallbackPanels: 0, + directMappedHardware: 0, + fallbackHardware: 0, + }, + warnings: [], + errors: [], + }, + }), + ).rejects.toThrow('默认 SKU 编码「SPU-001-CAD」已存在') + expect(productCreate).not.toHaveBeenCalled() + expect(skuCreate).not.toHaveBeenCalled() + }) }) diff --git a/furniture-3d-system/src/server/repositories/asset-center.repository.ts b/furniture-3d-system/src/server/repositories/asset-center.repository.ts index 7596836c..23676d97 100644 --- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts +++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts @@ -7,6 +7,7 @@ import { getRestorableAssetVariantCode, getRestorableAssetVariantIdentityKey, } from '@/lib/open-product/asset-variant-soft-delete' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' export interface AssetCenterFinishRelation { id: string @@ -230,7 +231,7 @@ class AssetFamilyRepositoryClass extends BaseRepository< protected modelName = 'assetFamily' async findByCode(code: string): Promise { - return this.prisma.assetFamily.findUnique({ where: { code } }) + return this.prisma.assetFamily.findUnique({ where: { code: normalizeCoreIdentity(code) } }) } async findWithRelationsById(id: string): Promise { @@ -364,7 +365,7 @@ class AssetSeriesRepositoryClass extends BaseRepository< } async findByCode(code: string): Promise { - return this.prisma.assetSeries.findUnique({ where: { code } }) + return this.prisma.assetSeries.findUnique({ where: { code: normalizeCoreIdentity(code) } }) } async findById(id: string): Promise { @@ -400,7 +401,7 @@ class AssetVariantRepositoryClass extends BaseRepository< async findByCode(code: string): Promise { return this.prisma.assetVariant.findFirst({ where: { - code, + code: normalizeCoreIdentity(code), deletedAt: null, assetFamily: { is: { deletedAt: null } }, }, @@ -410,7 +411,7 @@ class AssetVariantRepositoryClass extends BaseRepository< async findByBarcode(barcode: string): Promise { return this.prisma.assetVariant.findFirst({ where: { - barcode, + barcode: normalizeCoreIdentity(barcode), deletedAt: null, assetFamily: { is: { deletedAt: null } }, }, @@ -628,7 +629,7 @@ class SubstrateRepositoryClass extends BaseRepository< protected modelName = 'substrate' async findByCode(code: string): Promise { - return this.prisma.substrate.findUnique({ where: { code } }) + return this.prisma.substrate.findUnique({ where: { code: normalizeCoreIdentity(code) } }) } async findByIdWithRelations(id: string): Promise { diff --git a/furniture-3d-system/src/server/repositories/common-component.repository.ts b/furniture-3d-system/src/server/repositories/common-component.repository.ts index 9d614c7d..16035393 100644 --- a/furniture-3d-system/src/server/repositories/common-component.repository.ts +++ b/furniture-3d-system/src/server/repositories/common-component.repository.ts @@ -1,5 +1,6 @@ import { CommonComponent, CommonComponentCategory, Prisma } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' export interface CommonComponentFilter { search?: string @@ -34,13 +35,13 @@ class CommonComponentRepositoryClass extends BaseRepository< protected modelName = 'commonComponent' async findByCode(code: string): Promise { - return this.prisma.commonComponent.findUnique({ where: { code } }) + return this.prisma.commonComponent.findUnique({ where: { code: normalizeCoreIdentity(code) } }) } async codeExists(code: string, excludeId?: string): Promise { const count = await this.prisma.commonComponent.count({ where: { - code, + code: normalizeCoreIdentity(code), id: excludeId ? { not: excludeId } : undefined, }, }) diff --git a/furniture-3d-system/src/server/repositories/draft-group.repository.ts b/furniture-3d-system/src/server/repositories/draft-group.repository.ts index 9c3e7818..c1e0ab7e 100644 --- a/furniture-3d-system/src/server/repositories/draft-group.repository.ts +++ b/furniture-3d-system/src/server/repositories/draft-group.repository.ts @@ -1,4 +1,4 @@ -import { DraftGroup, PackagedItem, PreSku, PreSkuPackagingPlan, Prisma } from '@prisma/client' +import { DraftGroup, PackagedItem, PreSku, PreSkuBasicInfo, PreSkuPackagingPlan, Prisma } from '@prisma/client' import { BaseRepository } from './base.repository' export interface DraftGroupWithRelations extends DraftGroup { @@ -8,6 +8,7 @@ export interface DraftGroupWithRelations extends DraftGroup { } export interface PreSkuWithRelations extends PreSku { + basicInfo: PreSkuBasicInfo | null packagingPlan: (PreSkuPackagingPlan & { packagedItems: PackagedItem[] }) | null } @@ -114,6 +115,7 @@ class PreSkuRepositoryClass extends BaseRepository< return this.prisma.preSku.findUnique({ where: { id }, include: { + basicInfo: true, packagingPlan: { include: { packagedItems: true, @@ -128,6 +130,7 @@ class PreSkuRepositoryClass extends BaseRepository< where: { draftGroupId }, orderBy: { sequence: 'asc' }, include: { + basicInfo: true, packagingPlan: { include: { packagedItems: true, @@ -178,6 +181,7 @@ class PreSkuRepositoryClass extends BaseRepository< where: { id }, data: { displaySummary }, include: { + basicInfo: true, packagingPlan: { include: { packagedItems: true, @@ -187,7 +191,32 @@ class PreSkuRepositoryClass extends BaseRepository< }) as Promise } - async findByDisplaySummaryField( + async upsertBasicInfo( + preSkuId: string, + data: Omit, + ): Promise { + return this.prisma.preSku.update({ + where: { id: preSkuId }, + data: { + basicInfo: { + upsert: { + create: data, + update: data, + }, + }, + }, + include: { + basicInfo: true, + packagingPlan: { + include: { + packagedItems: true, + }, + }, + }, + }) as Promise + } + + async findByBasicInfoField( field: string, value: string, excludeId?: string, @@ -195,9 +224,10 @@ class PreSkuRepositoryClass extends BaseRepository< return this.prisma.preSku.findMany({ where: { id: excludeId ? { not: excludeId } : undefined, - displaySummary: { - path: [field], - equals: value, + basicInfo: { + is: { + [field]: value, + }, }, }, select: { @@ -209,7 +239,7 @@ class PreSkuRepositoryClass extends BaseRepository< }) } - async findByDisplaySummaryFields( + async findByBasicInfoFields( fields: Record, excludeId?: string, ): Promise> { @@ -219,12 +249,9 @@ class PreSkuRepositoryClass extends BaseRepository< return this.prisma.preSku.findMany({ where: { id: excludeId ? { not: excludeId } : undefined, - AND: entries.map(([field, value]) => ({ - displaySummary: { - path: [field], - equals: value, - }, - })), + basicInfo: { + is: Object.fromEntries(entries), + }, }, select: { id: true, diff --git a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts index 58fbf878..ccff81be 100644 --- a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts +++ b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts @@ -49,6 +49,25 @@ function normalizeEmployeeIdentity(value: string | null | undefined): string | n return text ? text.toLowerCase() : null } +function normalizeRequiredText(value: string | null | undefined): string { + return String(value || '').trim() +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + const text = normalizeRequiredText(value) + return text || null +} + +function normalizeOptionalEmail(value: string | null | undefined): string | null { + const text = normalizeOptionalText(value) + return text ? text.toLowerCase() : null +} + +function normalizeDepartmentExternalIdsValue(value: Prisma.InputJsonValue | undefined): Prisma.InputJsonValue | undefined { + if (value === undefined) return undefined + return Array.from(new Set(parseDepartmentExternalIds(value))) +} + function buildEmployeeIdentityKey(field: EmployeeIdentityField, value: string | null | undefined): string | null { const normalized = normalizeEmployeeIdentity(value) return normalized ? `${field}:${normalized}` : null @@ -189,13 +208,13 @@ class EmployeeDirectoryRepository { const orConditions: Prisma.EmployeeDirectoryWhereInput[] = [] if (input.dingtalkUserId) { - orConditions.push({ dingtalkUserId: input.dingtalkUserId }) + orConditions.push({ dingtalkUserId: normalizeRequiredText(input.dingtalkUserId) }) } if (input.dingtalkUnionId) { - orConditions.push({ dingtalkUnionId: input.dingtalkUnionId }) + orConditions.push({ dingtalkUnionId: normalizeRequiredText(input.dingtalkUnionId) }) } if (input.mobile) { - orConditions.push({ mobile: input.mobile }) + orConditions.push({ mobile: normalizeRequiredText(input.mobile) }) } if (orConditions.length === 0) { @@ -249,11 +268,15 @@ class EmployeeDirectoryRepository { async upsertOne(input: EmployeeDirectoryUpsertInput): Promise { const now = new Date() const primaryDepartmentExternalId = await this.choosePrimaryDepartmentExternalId(input) + const dingtalkUserId = normalizeOptionalText(input.dingtalkUserId) + const dingtalkUnionId = normalizeOptionalText(input.dingtalkUnionId) + const jobNumber = normalizeOptionalText(input.jobNumber) + const mobile = normalizeOptionalText(input.mobile) const matchConditions = [ - input.jobNumber ? { jobNumber: input.jobNumber } : undefined, - input.dingtalkUserId ? { dingtalkUserId: input.dingtalkUserId } : undefined, - input.dingtalkUnionId ? { dingtalkUnionId: input.dingtalkUnionId } : undefined, - input.mobile ? { mobile: input.mobile } : undefined, + jobNumber ? { jobNumber } : undefined, + dingtalkUserId ? { dingtalkUserId } : undefined, + dingtalkUnionId ? { dingtalkUnionId } : undefined, + mobile ? { mobile } : undefined, ].filter(Boolean) as Prisma.EmployeeDirectoryWhereInput[] const existingEmployee = matchConditions.length @@ -264,16 +287,16 @@ class EmployeeDirectoryRepository { : null const data = { - dingtalkUserId: input.dingtalkUserId ?? null, - dingtalkUnionId: input.dingtalkUnionId ?? null, - jobNumber: input.jobNumber ?? null, - name: input.name, - mobile: input.mobile ?? null, - email: input.email ?? null, - title: input.title ?? null, + dingtalkUserId, + dingtalkUnionId, + jobNumber, + name: normalizeRequiredText(input.name), + mobile, + email: normalizeOptionalEmail(input.email), + title: normalizeOptionalText(input.title), sourceStatus: input.sourceStatus ?? 'EMPLOYED', primaryDepartmentExternalId, - departmentExternalIds: input.departmentExternalIds, + departmentExternalIds: normalizeDepartmentExternalIdsValue(input.departmentExternalIds), rawSnapshot: input.rawSnapshot, syncedAt: now, } satisfies Prisma.EmployeeDirectoryUncheckedCreateInput @@ -325,6 +348,12 @@ class EmployeeDirectoryRepository { const now = new Date() const departmentMap = new Map() const activeEmployeeIdentityKeys = new Set() + const departments = input.departments.map((department) => ({ + ...department, + externalId: normalizeRequiredText(department.externalId), + name: normalizeRequiredText(department.name), + parentExternalId: normalizeOptionalText(department.parentExternalId), + })) for (const employee of input.employees) { addEmployeeIdentityKey(activeEmployeeIdentityKeys, 'dingtalkUserId', employee.dingtalkUserId) @@ -333,7 +362,7 @@ class EmployeeDirectoryRepository { addEmployeeIdentityKey(activeEmployeeIdentityKeys, 'jobNumber', employee.jobNumber) } - const sortedDepartments = [...input.departments].sort((a, b) => { + const sortedDepartments = [...departments].sort((a, b) => { const aDepth = a.parentExternalId ? 1 : 0 const bDepth = b.parentExternalId ? 1 : 0 if (aDepth !== bDepth) return aDepth - bDepth @@ -346,21 +375,24 @@ class EmployeeDirectoryRepository { let progressed = false for (const item of unresolvedDepartments) { - if (item.parentExternalId && !departmentMap.has(item.parentExternalId)) { + const parentExternalId = item.parentExternalId + if (parentExternalId && !departmentMap.has(parentExternalId)) { remaining.push(item) continue } - const parent = item.parentExternalId ? departmentMap.get(item.parentExternalId) ?? null : null + const parent = parentExternalId ? departmentMap.get(parentExternalId) ?? null : null + const externalId = item.externalId + const name = item.name const departmentName = await this.resolveUniqueDepartmentName(prisma, { - externalId: item.externalId, - name: item.name, + externalId, + name, parentFullPath: parent?.fullPath ?? null, }) const fullPath = parent ? `${parent.fullPath} > ${departmentName}` : departmentName const department = await prisma.department.upsert({ - where: { externalId: item.externalId }, + where: { externalId }, update: { name: departmentName, fullPath, @@ -372,7 +404,7 @@ class EmployeeDirectoryRepository { create: { name: departmentName, fullPath, - externalId: item.externalId, + externalId, parentId: parent?.id ?? null, orderIndex: item.orderIndex ?? 0, rawSnapshot: item.rawSnapshot, @@ -380,7 +412,7 @@ class EmployeeDirectoryRepository { }, }) - departmentMap.set(item.externalId, { id: department.id, fullPath }) + departmentMap.set(externalId, { id: department.id, fullPath }) progressed = true } @@ -402,11 +434,15 @@ class EmployeeDirectoryRepository { }), ) ?? employee.primaryDepartmentExternalId ?? null + const dingtalkUserId = normalizeOptionalText(employee.dingtalkUserId) + const dingtalkUnionId = normalizeOptionalText(employee.dingtalkUnionId) + const mobile = normalizeOptionalText(employee.mobile) + const jobNumber = normalizeOptionalText(employee.jobNumber) const matchConditions = [ - employee.dingtalkUserId ? { dingtalkUserId: employee.dingtalkUserId } : undefined, - employee.dingtalkUnionId ? { dingtalkUnionId: employee.dingtalkUnionId } : undefined, - employee.mobile ? { mobile: employee.mobile } : undefined, - employee.jobNumber ? { jobNumber: employee.jobNumber } : undefined, + dingtalkUserId ? { dingtalkUserId } : undefined, + dingtalkUnionId ? { dingtalkUnionId } : undefined, + mobile ? { mobile } : undefined, + jobNumber ? { jobNumber } : undefined, ].filter(Boolean) as Prisma.EmployeeDirectoryWhereInput[] const existingEmployee = matchConditions.length @@ -426,16 +462,16 @@ class EmployeeDirectoryRepository { const employeeRecord = await prisma.employeeDirectory.update({ where: { id: existingEmployee.id }, data: { - dingtalkUserId: employee.dingtalkUserId ?? null, - dingtalkUnionId: employee.dingtalkUnionId ?? null, - jobNumber: employee.jobNumber ?? null, - name: employee.name, - mobile: employee.mobile ?? null, - email: employee.email ?? null, - title: employee.title ?? null, + dingtalkUserId, + dingtalkUnionId, + jobNumber, + name: normalizeRequiredText(employee.name), + mobile, + email: normalizeOptionalEmail(employee.email), + title: normalizeOptionalText(employee.title), sourceStatus: 'EMPLOYED', primaryDepartmentExternalId, - departmentExternalIds: employee.departmentExternalIds, + departmentExternalIds: normalizeDepartmentExternalIdsValue(employee.departmentExternalIds), rawSnapshot: employee.rawSnapshot, syncedAt: now, }, @@ -450,16 +486,16 @@ class EmployeeDirectoryRepository { const employeeRecord = await prisma.employeeDirectory.create({ data: { - dingtalkUserId: employee.dingtalkUserId ?? null, - dingtalkUnionId: employee.dingtalkUnionId ?? null, - jobNumber: employee.jobNumber ?? null, - name: employee.name, - mobile: employee.mobile ?? null, - email: employee.email ?? null, - title: employee.title ?? null, + dingtalkUserId, + dingtalkUnionId, + jobNumber, + name: normalizeRequiredText(employee.name), + mobile, + email: normalizeOptionalEmail(employee.email), + title: normalizeOptionalText(employee.title), sourceStatus: 'EMPLOYED', primaryDepartmentExternalId, - departmentExternalIds: employee.departmentExternalIds, + departmentExternalIds: normalizeDepartmentExternalIdsValue(employee.departmentExternalIds), rawSnapshot: employee.rawSnapshot, syncedAt: now, }, @@ -472,11 +508,15 @@ class EmployeeDirectoryRepository { } for (const dimission of input.dimissionEntries) { + const dingtalkUserId = normalizeOptionalText(dimission.dingtalkUserId) + const dingtalkUnionId = normalizeOptionalText(dimission.dingtalkUnionId) + const mobile = normalizeOptionalText(dimission.mobile) + const jobNumber = normalizeOptionalText(dimission.jobNumber) const activeIdentityMatched = [ - buildEmployeeIdentityKey('dingtalkUserId', dimission.dingtalkUserId), - buildEmployeeIdentityKey('dingtalkUnionId', dimission.dingtalkUnionId), - buildEmployeeIdentityKey('mobile', dimission.mobile), - buildEmployeeIdentityKey('jobNumber', dimission.jobNumber), + buildEmployeeIdentityKey('dingtalkUserId', dingtalkUserId), + buildEmployeeIdentityKey('dingtalkUnionId', dingtalkUnionId), + buildEmployeeIdentityKey('mobile', mobile), + buildEmployeeIdentityKey('jobNumber', jobNumber), ].some((key) => key !== null && activeEmployeeIdentityKeys.has(key)) if (activeIdentityMatched) { @@ -484,10 +524,10 @@ class EmployeeDirectoryRepository { } const dimissionConditions = [ - dimission.dingtalkUserId ? { dingtalkUserId: dimission.dingtalkUserId } : undefined, - dimission.dingtalkUnionId ? { dingtalkUnionId: dimission.dingtalkUnionId } : undefined, - dimission.mobile ? { mobile: dimission.mobile } : undefined, - dimission.jobNumber ? { jobNumber: dimission.jobNumber } : undefined, + dingtalkUserId ? { dingtalkUserId } : undefined, + dingtalkUnionId ? { dingtalkUnionId } : undefined, + mobile ? { mobile } : undefined, + jobNumber ? { jobNumber } : undefined, ].filter(Boolean) as Prisma.EmployeeDirectoryWhereInput[] if (dimissionConditions.length === 0) { diff --git a/furniture-3d-system/src/server/repositories/feedback.repository.ts b/furniture-3d-system/src/server/repositories/feedback.repository.ts index 481f844f..c83b00f5 100644 --- a/furniture-3d-system/src/server/repositories/feedback.repository.ts +++ b/furniture-3d-system/src/server/repositories/feedback.repository.ts @@ -48,6 +48,17 @@ export interface FeedbackWithRelations extends Feedback { } | null } +function normalizeRequiredText(value: unknown, message: string): string { + if (typeof value !== 'string') { + throw new Error(message) + } + const normalized = value.trim() + if (!normalized) { + throw new Error(message) + } + return normalized +} + // ============ 反馈 Repository ============ class FeedbackRepositoryClass extends BaseRepository< @@ -208,11 +219,13 @@ class FeedbackRepositoryClass extends BaseRepository< * 标记为已解决 */ async markAsResolved(id: string, resolution: string): Promise { + const normalizedId = normalizeRequiredText(id, '反馈 ID 不能为空') + const normalizedResolution = normalizeRequiredText(resolution, '解决方案不能为空') return this.prisma.feedback.update({ - where: { id }, + where: { id: normalizedId }, data: { status: 'RESOLVED', - resolution, + resolution: normalizedResolution, resolvedAt: new Date(), }, }) @@ -225,19 +238,25 @@ class FeedbackRepositoryClass extends BaseRepository< id: string, comment: { userId: string; userName: string; content: string } ): Promise { - const feedback = await this.findById(id) + const normalizedId = normalizeRequiredText(id, '反馈 ID 不能为空') + const normalizedComment = { + userId: normalizeRequiredText(comment.userId, '评论人不能为空'), + userName: normalizeRequiredText(comment.userName, '评论人名称不能为空'), + content: normalizeRequiredText(comment.content, '评论内容不能为空'), + } + const feedback = await this.findById(normalizedId) if (!feedback) { throw new Error('反馈不存在') } const comments = (feedback.comments as Array>) || [] comments.push({ - ...comment, + ...normalizedComment, createdAt: new Date().toISOString(), }) return this.prisma.feedback.update({ - where: { id }, + where: { id: normalizedId }, data: { status: 'IN_PROGRESS', comments: comments as unknown as Prisma.InputJsonValue, @@ -249,9 +268,11 @@ class FeedbackRepositoryClass extends BaseRepository< * 重新分配处理者 */ async reassign(id: string, assigneeId: string): Promise { + const normalizedId = normalizeRequiredText(id, '反馈 ID 不能为空') + const normalizedAssigneeId = normalizeRequiredText(assigneeId, '处理人不能为空') return this.prisma.feedback.update({ - where: { id }, - data: { assigneeId }, + where: { id: normalizedId }, + data: { assigneeId: normalizedAssigneeId }, }) } diff --git a/furniture-3d-system/src/server/repositories/hardware.repository.ts b/furniture-3d-system/src/server/repositories/hardware.repository.ts index 6083001c..fdbd152e 100644 --- a/furniture-3d-system/src/server/repositories/hardware.repository.ts +++ b/furniture-3d-system/src/server/repositories/hardware.repository.ts @@ -8,6 +8,7 @@ import { Hardware, HardwareCategory, HardwareMapping, Prisma } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' // ============ 类型定义 ============ @@ -66,7 +67,7 @@ class HardwareRepositoryClass extends BaseRepository< */ async findByCode(code: string): Promise { return this.prisma.hardware.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, }) } @@ -76,7 +77,7 @@ class HardwareRepositoryClass extends BaseRepository< async codeExists(code: string, excludeId?: string): Promise { const count = await this.prisma.hardware.count({ where: { - code, + code: normalizeCoreIdentity(code), id: excludeId ? { not: excludeId } : undefined, }, }) diff --git a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts index 8735c20d..615e7cca 100644 --- a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts +++ b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts @@ -4,6 +4,28 @@ import { BaseRepository } from './base.repository' export type IntakeNonIntegratedProductCreateInput = Prisma.IntakeNonIntegratedProductCreateInput export type IntakeNonIntegratedProductUpdateInput = Prisma.IntakeNonIntegratedProductUpdateInput +type IntakeNonIntegratedProductTx = Prisma.TransactionClient +type StructuredSkuItemsNestedInput = { + createMany?: { + data?: + | Prisma.IntakeNonIntegratedSkuItemCreateManyProductInput + | Prisma.IntakeNonIntegratedSkuItemCreateManyProductInput[] + } +} + +function extractStructuredSkuItems(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return [] + } + + const createMany = (value as StructuredSkuItemsNestedInput).createMany + const data = createMany?.data + if (!data) { + return [] + } + + return Array.isArray(data) ? data : [data] +} class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< IntakeNonIntegratedProduct, @@ -13,6 +35,110 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< > { protected modelName = 'intakeNonIntegratedProduct' + async create(data: IntakeNonIntegratedProductCreateInput) { + return this.createWithClient(this.prisma, data) + } + + async createInTransaction(tx: IntakeNonIntegratedProductTx, data: IntakeNonIntegratedProductCreateInput) { + return this.createWithClient(tx, data) + } + + private async createWithClient( + client: typeof this.prisma | IntakeNonIntegratedProductTx, + data: IntakeNonIntegratedProductCreateInput, + ) { + const { skuItems, ...productData } = data as IntakeNonIntegratedProductCreateInput & { + skuItems?: StructuredSkuItemsNestedInput + } + const structuredSkuItems = extractStructuredSkuItems(skuItems) + + if (structuredSkuItems.length === 0) { + return client.intakeNonIntegratedProduct.create({ + data: productData as IntakeNonIntegratedProductCreateInput, + }) + } + + const createRecord = async (tx: IntakeNonIntegratedProductTx) => { + const record = await tx.intakeNonIntegratedProduct.create({ + data: productData as IntakeNonIntegratedProductCreateInput, + }) + + await tx.intakeNonIntegratedSkuItem.createMany({ + data: structuredSkuItems.map((item) => ({ + ...item, + productId: record.id, + })), + }) + + return record + } + + if ('$transaction' in client) { + return client.$transaction((tx) => createRecord(tx)) + } + + return createRecord(client) + } + + async update(id: string, data: IntakeNonIntegratedProductUpdateInput) { + return this.updateWithClient(this.prisma, id, data) + } + + async updateInTransaction( + tx: IntakeNonIntegratedProductTx, + id: string, + data: IntakeNonIntegratedProductUpdateInput, + ) { + return this.updateWithClient(tx, id, data) + } + + private async updateWithClient( + client: typeof this.prisma | IntakeNonIntegratedProductTx, + id: string, + data: IntakeNonIntegratedProductUpdateInput, + ) { + const { skuItems, ...productData } = data as IntakeNonIntegratedProductUpdateInput & { + skuItems?: StructuredSkuItemsNestedInput + } + const shouldRefreshStructuredSkuItems = Boolean(skuItems) + const structuredSkuItems = extractStructuredSkuItems(skuItems) + + if (!shouldRefreshStructuredSkuItems) { + return client.intakeNonIntegratedProduct.update({ + where: { id }, + data: productData as IntakeNonIntegratedProductUpdateInput, + }) + } + + const updateRecord = async (tx: IntakeNonIntegratedProductTx) => { + const record = await tx.intakeNonIntegratedProduct.update({ + where: { id }, + data: productData as IntakeNonIntegratedProductUpdateInput, + }) + + await tx.intakeNonIntegratedSkuItem.deleteMany({ + where: { productId: record.id }, + }) + + if (structuredSkuItems.length > 0) { + await tx.intakeNonIntegratedSkuItem.createMany({ + data: structuredSkuItems.map((item) => ({ + ...item, + productId: record.id, + })), + }) + } + + return record + } + + if ('$transaction' in client) { + return client.$transaction((tx) => updateRecord(tx)) + } + + return updateRecord(client) + } + private buildActiveWhere(scopeWhere?: Prisma.IntakeNonIntegratedProductWhereInput) { return { deletedAt: null, @@ -27,6 +153,14 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< async listActive(scopeWhere?: Prisma.IntakeNonIntegratedProductWhereInput) { return this.prisma.intakeNonIntegratedProduct.findMany({ where: this.buildActiveWhere(scopeWhere), + include: { + profile: true, + skuItems: { + orderBy: { + sortOrder: 'asc', + }, + }, + }, orderBy: { createdAt: 'desc', }, @@ -50,6 +184,14 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< const [records, total] = await this.prisma.$transaction([ this.prisma.intakeNonIntegratedProduct.findMany({ where, + include: { + profile: true, + skuItems: { + orderBy: { + sortOrder: 'asc', + }, + }, + }, orderBy: { createdAt: 'desc', }, @@ -68,11 +210,37 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< id, deletedAt: null, }, + include: { + profile: true, + skuItems: { + orderBy: { + sortOrder: 'asc', + }, + }, + }, }) } async findBySpu(spu: string, excludeId?: string, brand?: string) { - return this.prisma.intakeNonIntegratedProduct.findFirst({ + return this.findBySpuWithClient(this.prisma, spu, excludeId, brand) + } + + async findBySpuInTransaction( + tx: IntakeNonIntegratedProductTx, + spu: string, + excludeId?: string, + brand?: string, + ) { + return this.findBySpuWithClient(tx, spu, excludeId, brand) + } + + private async findBySpuWithClient( + client: typeof this.prisma | IntakeNonIntegratedProductTx, + spu: string, + excludeId?: string, + brand?: string, + ) { + return client.intakeNonIntegratedProduct.findFirst({ where: { spu, ...(brand ? { brand } : null), @@ -89,7 +257,25 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< } async findDeletedBySpu(spu: string, excludeId?: string, brand?: string) { - const deletedRecords = await this.prisma.intakeNonIntegratedProduct.findMany({ + return this.findDeletedBySpuWithClient(this.prisma, spu, excludeId, brand) + } + + async findDeletedBySpuInTransaction( + tx: IntakeNonIntegratedProductTx, + spu: string, + excludeId?: string, + brand?: string, + ) { + return this.findDeletedBySpuWithClient(tx, spu, excludeId, brand) + } + + private async findDeletedBySpuWithClient( + client: typeof this.prisma | IntakeNonIntegratedProductTx, + spu: string, + excludeId?: string, + brand?: string, + ) { + const deletedRecords = await client.intakeNonIntegratedProduct.findMany({ where: { spu, deletedAt: { @@ -126,11 +312,14 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< data: { deletedAt: new Date(), updatedBy, + skuItems: { + deleteMany: {}, + }, }, }) } - async updatePayloadByUser(id: string, payload: Prisma.InputJsonValue, updatedBy: string) { + async updateAttachmentPayloadByUser(id: string, payload: Prisma.InputJsonValue, updatedBy: string) { return this.prisma.intakeNonIntegratedProduct.update({ where: { id }, data: { diff --git a/furniture-3d-system/src/server/repositories/log.repository.ts b/furniture-3d-system/src/server/repositories/log.repository.ts index 5cf994e8..e5dcbd92 100644 --- a/furniture-3d-system/src/server/repositories/log.repository.ts +++ b/furniture-3d-system/src/server/repositories/log.repository.ts @@ -36,6 +36,24 @@ export interface LogWithUser extends ActivityLog { } } +function normalizeRequiredText(value: unknown, message: string): string { + if (typeof value !== 'string') { + throw new Error(message) + } + const normalized = value.trim() + if (!normalized) { + throw new Error(message) + } + return normalized +} + +function normalizeOptionalText(value: unknown): string | null { + if (value == null) return null + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized ? normalized : null +} + // ============ 日志 Repository ============ class LogRepositoryClass extends BaseRepository< @@ -221,14 +239,19 @@ class LogRepositoryClass extends BaseRepository< metadata?: object }, tx?: Prisma.TransactionClient): Promise { const client = tx ?? this.prisma + const targetType = normalizeOptionalText(data.targetType) + const targetId = normalizeOptionalText(data.targetId) + if (Boolean(targetType) !== Boolean(targetId)) { + throw new Error('操作日志目标类型和目标 ID 必须同时填写') + } return client.activityLog.create({ data: { - user: { connect: { id: data.userId } }, + user: { connect: { id: normalizeRequiredText(data.userId, '用户 ID 不能为空') } }, type: data.type, - action: data.action, - targetType: data.targetType, - targetId: data.targetId, + action: normalizeRequiredText(data.action, '操作动作不能为空'), + targetType, + targetId, changes: data.changes, metadata: data.metadata, }, diff --git a/furniture-3d-system/src/server/repositories/material.repository.ts b/furniture-3d-system/src/server/repositories/material.repository.ts index f2108da7..00dca225 100644 --- a/furniture-3d-system/src/server/repositories/material.repository.ts +++ b/furniture-3d-system/src/server/repositories/material.repository.ts @@ -8,6 +8,7 @@ import { Material, MaterialCategory, Prisma } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' // ============ 类型定义 ============ @@ -55,14 +56,14 @@ class MaterialRepositoryClass extends BaseRepository< async findByCode(code: string): Promise { return this.prisma.material.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, }) } async findLatestGeneratedCode(prefix: string): Promise { const record = await this.prisma.material.findFirst({ where: { - code: { startsWith: prefix }, + code: { startsWith: normalizeCoreIdentity(prefix) }, }, orderBy: { code: 'desc', diff --git a/furniture-3d-system/src/server/repositories/planning.repository.ts b/furniture-3d-system/src/server/repositories/planning.repository.ts index b4641d0c..dfb916b8 100644 --- a/furniture-3d-system/src/server/repositories/planning.repository.ts +++ b/furniture-3d-system/src/server/repositories/planning.repository.ts @@ -4,6 +4,7 @@ import { PlanningWorkspace, Prisma, } from '@prisma/client' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { BaseRepository } from './base.repository' export interface PlanningTemplateFilter { @@ -43,14 +44,14 @@ class PlanningTemplateRepositoryClass extends BaseRepository< async findByCode(code: string): Promise { return this.prisma.planningTemplate.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, }) } async codeExists(code: string, excludeId?: string): Promise { const count = await this.prisma.planningTemplate.count({ where: { - code, + code: normalizeCoreIdentity(code), id: excludeId ? { not: excludeId } : undefined, }, }) diff --git a/furniture-3d-system/src/server/repositories/product-upload.repository.ts b/furniture-3d-system/src/server/repositories/product-upload.repository.ts index cc918e04..a9827e99 100644 --- a/furniture-3d-system/src/server/repositories/product-upload.repository.ts +++ b/furniture-3d-system/src/server/repositories/product-upload.repository.ts @@ -5,6 +5,7 @@ */ import { Prisma } from '@prisma/client' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { prisma } from '@/lib/prisma' export interface UploadPanelInput { @@ -36,7 +37,7 @@ export interface CreateUploadedProductInput { class ProductUploadRepository { async findByModelNumber(modelNumber: string) { return prisma.product.findUnique({ - where: { modelNumber }, + where: { modelNumber: normalizeCoreIdentity(modelNumber) }, select: { id: true }, }) } @@ -64,11 +65,14 @@ class ProductUploadRepository { skuCode, skuAttributes, } = input + const normalizedModelNumber = normalizeCoreIdentity(modelNumber) + const normalizedSkuCode = normalizeCoreIdentity(skuCode) + const skuName = normalizedSkuCode return prisma.$transaction(async (tx) => { const product = await tx.product.create({ data: { - modelNumber, + modelNumber: normalizedModelNumber, name, categoryId, description: description?.trim() || null, @@ -84,7 +88,8 @@ class ProductUploadRepository { data: { productId: product.id, ownerId: createdBy, - skuCode, + skuCode: normalizedSkuCode, + name: skuName, attributes: skuAttributes, price: 0, costPrice: 0, diff --git a/furniture-3d-system/src/server/repositories/product.repository.ts b/furniture-3d-system/src/server/repositories/product.repository.ts index 67bc42ce..801f3fcd 100644 --- a/furniture-3d-system/src/server/repositories/product.repository.ts +++ b/furniture-3d-system/src/server/repositories/product.repository.ts @@ -9,6 +9,14 @@ import { Product, Prisma } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' import type { ProcessingResult } from '@/lib/cad-export-processor' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' +import { + buildDeletedSkuCode, + getRestorableSkuCode, + removeSoftDeleteLifecycleAttributes, + releaseSkuCodeFromDeletedRecordsTx, + toSkuAttributesObject, +} from '../services/sku-soft-delete.helper' // ============ 类型定义 ============ @@ -47,7 +55,7 @@ class ProductRepositoryClass extends BaseRepository< */ async findByModelNumber(modelNumber: string): Promise { return this.prisma.product.findUnique({ - where: { modelNumber }, + where: { modelNumber: normalizeCoreIdentity(modelNumber) }, }) } @@ -57,7 +65,7 @@ class ProductRepositoryClass extends BaseRepository< async modelNumberExists(modelNumber: string, excludeId?: string): Promise { const count = await this.prisma.product.count({ where: { - modelNumber, + modelNumber: normalizeCoreIdentity(modelNumber), id: excludeId ? { not: excludeId } : undefined, }, }) @@ -96,8 +104,9 @@ class ProductRepositoryClass extends BaseRepository< skus: { where: { deletedAt: null }, orderBy: { createdAt: 'asc' }, - // 确保包含 deletedAt 字段用于二次过滤 - include: {}, + include: { + assetProfile: true, + }, }, shareLinks: { select: { token: true, viewCount: true }, @@ -136,6 +145,7 @@ class ProductRepositoryClass extends BaseRepository< owner: { select: { id: true, name: true, avatar: true }, }, + assetProfile: true, }, orderBy: { createdAt: 'asc' }, }, @@ -465,8 +475,7 @@ class ProductRepositoryClass extends BaseRepository< async softDeleteWithCascade( productId: string, - deletedAt: Date, - marker: string + deletedAt: Date ): Promise<{ count: number }> { return this.prisma.$transaction(async (tx) => { await tx.product.update({ @@ -477,35 +486,40 @@ class ProductRepositoryClass extends BaseRepository< const skus = await tx.productSKU.findMany({ where: { productId, deletedAt: null }, - select: { id: true, skuCode: true, attributes: true }, + select: { id: true, skuCode: true, attributes: true, originalSkuCode: true }, }) for (const sku of skus) { - const attributesObject = this.toAttributesObject(sku.attributes) - const originalSkuCode = this.getRestorableSkuCode(sku.skuCode, sku.attributes, marker) + const attributesObject = removeSoftDeleteLifecycleAttributes( + toSkuAttributesObject(sku.attributes), + ) + const originalSkuCode = getRestorableSkuCode(sku.skuCode, sku.originalSkuCode) await tx.productSKU.update({ where: { id: sku.id }, data: { deletedAt, - skuCode: this.buildDeletedSkuCode(sku.skuCode, sku.id, deletedAt, marker), + skuCode: buildDeletedSkuCode(originalSkuCode, sku.id, deletedAt), + originalSkuCode, + deletedCodeAt: deletedAt, + deletedByProductId: productId, + productDeletedAt: deletedAt, attributes: { ...attributesObject, - _originalSkuCode: originalSkuCode, - _deletedCodeAt: deletedAt.toISOString(), } as Prisma.InputJsonValue, }, }) + await tx.productSkuAssetProfile.updateMany({ + where: { skuId: sku.id }, + data: { skuDeletedAt: deletedAt }, + }) } return { count: skus.length } }) } - async restoreWithSkus( - productId: string, - marker: string - ): Promise<{ count: number }> { + async restoreWithSkus(productId: string): Promise<{ count: number }> { return this.prisma.$transaction(async (tx) => { await tx.product.update({ where: { id: productId }, @@ -515,45 +529,84 @@ class ProductRepositoryClass extends BaseRepository< const skus = await tx.productSKU.findMany({ where: { productId, deletedAt: { not: null } }, - select: { id: true, skuCode: true, attributes: true }, + select: { + id: true, + skuCode: true, + attributes: true, + originalSkuCode: true, + deletedByProductId: true, + }, }) let restoredCount = 0 for (const sku of skus) { - let baseSkuCode = this.getRestorableSkuCode(sku.skuCode, sku.attributes, marker) - let candidate = baseSkuCode - let suffix = 0 - - while (true) { - const codeExists = await tx.productSKU.count({ - where: { - skuCode: candidate, - deletedAt: null, - id: { not: sku.id }, - }, - }) - if (codeExists === 0) break - suffix += 1 - candidate = `${baseSkuCode}-${suffix}` + const attributesObject = toSkuAttributesObject(sku.attributes) + if (sku.deletedByProductId !== productId) { + continue } + const baseSkuCode = getRestorableSkuCode(sku.skuCode, sku.originalSkuCode) if (!baseSkuCode) { - baseSkuCode = candidate + throw new Error(`无法恢复 SKU ${sku.id}:缺少原始 SKU 编码`) } - const attributesObject = this.toAttributesObject(sku.attributes) - delete attributesObject._originalSkuCode - delete attributesObject._deletedCodeAt + const codeExists = await tx.productSKU.count({ + where: { + skuCode: baseSkuCode, + deletedAt: null, + id: { not: sku.id }, + }, + }) + if (codeExists > 0) { + throw new Error( + `无法恢复 SKU ${sku.id}:原始 SKU 编码「${baseSkuCode}」已被占用,请先人工处理编码冲突`, + ) + } + + const restoredAttributes = removeSoftDeleteLifecycleAttributes(attributesObject) + + const assetProfile = await tx.productSkuAssetProfile.findUnique({ + where: { skuId: sku.id }, + select: { barcode: true, sourceStatus: true }, + }) + const activeBarcode = + assetProfile?.sourceStatus === 'ACTIVE' && assetProfile.barcode?.trim() + ? assetProfile.barcode.trim().toUpperCase() + : null + if (activeBarcode) { + const barcodeConflict = await tx.productSkuAssetProfile.findFirst({ + where: { + skuId: { not: sku.id }, + sourceStatus: 'ACTIVE', + skuDeletedAt: null, + barcode: { equals: activeBarcode, mode: 'insensitive' }, + }, + select: { skuId: true }, + }) + if (barcodeConflict) { + throw new Error( + `无法恢复 SKU ${sku.id}:条码「${activeBarcode}」已被占用,请先人工处理条码冲突`, + ) + } + } await tx.productSKU.update({ where: { id: sku.id }, data: { deletedAt: null, - skuCode: candidate, - attributes: attributesObject as Prisma.InputJsonValue, + skuCode: baseSkuCode, + originalSkuCode: null, + deletedCodeAt: null, + deletedByProductId: null, + productDeletedAt: null, + attributes: restoredAttributes as Prisma.InputJsonValue, }, }) + await tx.productSkuAssetProfile.updateMany({ + where: { skuId: sku.id }, + data: { skuDeletedAt: null }, + }) restoredCount += 1 } @@ -578,6 +631,21 @@ class ProductRepositoryClass extends BaseRepository< panels: Array<{ id: string; code: string; name: string }> }> { return this.prisma.$transaction(async (tx) => { + const baseSkuCode = `${input.modelNumber}-CAD` + await releaseSkuCodeFromDeletedRecordsTx(tx, baseSkuCode) + const existingSku = await tx.productSKU.findFirst({ + where: { + skuCode: baseSkuCode, + deletedAt: null, + }, + select: { id: true }, + }) + if (existingSku) { + throw new Error( + `无法导入 CAD:默认 SKU 编码「${baseSkuCode}」已存在,请先修正型号或选择显式 SKU 编码`, + ) + } + const product = await tx.product.create({ data: { modelNumber: input.modelNumber, @@ -592,42 +660,22 @@ class ProductRepositoryClass extends BaseRepository< select: { id: true }, }) - const baseSkuCode = `${input.modelNumber}-CAD` - let skuCode = baseSkuCode - let suffix = 0 - let sku: { id: string; skuCode: string } | null = null - - while (!sku) { - try { - sku = await tx.productSKU.create({ - data: { - productId: product.id, - skuCode, - attributes: { _cadImport: true, _sourceFileName: input.sourceFileName } as Prisma.InputJsonValue, - price: 0, - costPrice: null, - stock: 0, - images: [], - modelUrl: input.modelUrl || null, - ownerId: input.userId, - isActive: true, - }, - select: { id: true, skuCode: true }, - }) - } catch (error) { - if ( - typeof error === 'object' && - error && - 'code' in error && - (error as { code?: string }).code === 'P2002' - ) { - suffix += 1 - skuCode = `${baseSkuCode}-${suffix}` - continue - } - throw error - } - } + const sku = await tx.productSKU.create({ + data: { + productId: product.id, + skuCode: baseSkuCode, + name: baseSkuCode, + attributes: { _cadImport: true, _sourceFileName: input.sourceFileName } as Prisma.InputJsonValue, + price: 0, + costPrice: null, + stock: 0, + images: [], + modelUrl: input.modelUrl || null, + ownerId: input.userId, + isActive: true, + }, + select: { id: true, skuCode: true }, + }) const panels: Array<{ id: string; code: string; name: string }> = [] const cadToPanelId = new Map() @@ -729,37 +777,6 @@ class ProductRepositoryClass extends BaseRepository< return where } - - private toAttributesObject(attributes: Prisma.JsonValue | null | undefined): Record { - if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) { - return {} - } - return { ...(attributes as Record) } - } - - private getRestorableSkuCode( - skuCode: string, - attributes: Prisma.JsonValue | null | undefined, - marker: string - ): string { - const attributesObject = this.toAttributesObject(attributes) - const fromAttributes = attributesObject._originalSkuCode - if (typeof fromAttributes === 'string' && fromAttributes.trim()) { - return fromAttributes.trim() - } - - const markerIndex = skuCode.indexOf(marker) - if (markerIndex > 0) { - return skuCode.slice(0, markerIndex) - } - - return skuCode - } - - private buildDeletedSkuCode(skuCode: string, id: string, deletedAt: Date, marker: string): string { - const suffix = `${deletedAt.getTime()}_${id.slice(-6)}` - return `${skuCode}${marker}${suffix}` - } } // 单例导出 diff --git a/furniture-3d-system/src/server/repositories/share.repository.ts b/furniture-3d-system/src/server/repositories/share.repository.ts index 83bc2d1e..6e3167b2 100644 --- a/furniture-3d-system/src/server/repositories/share.repository.ts +++ b/furniture-3d-system/src/server/repositories/share.repository.ts @@ -60,8 +60,10 @@ export type PublicShareDetail = Prisma.ShareLinkGetPayload<{ select: { id: true skuCode: true + name: true price: true attributes: true + assetProfile: true isActive: true modelUrl: true } @@ -172,8 +174,10 @@ class ShareRepositoryClass extends BaseRepository< select: { id: true, skuCode: true, + name: true, price: true, attributes: true, + assetProfile: true, isActive: true, modelUrl: true, }, diff --git a/furniture-3d-system/src/server/repositories/sku.repository.ts b/furniture-3d-system/src/server/repositories/sku.repository.ts index c72b02ed..fca25e74 100644 --- a/furniture-3d-system/src/server/repositories/sku.repository.ts +++ b/furniture-3d-system/src/server/repositories/sku.repository.ts @@ -8,6 +8,7 @@ import { ProductSKU, Prisma } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' // ============ 类型定义 ============ @@ -41,18 +42,15 @@ class SKURepositoryClass extends BaseRepository< */ async findBySkuCode(skuCode: string): Promise { return this.prisma.productSKU.findUnique({ - where: { skuCode }, + where: { skuCode: normalizeCoreIdentity(skuCode) }, }) } - async findByAssetSnapshotBarcode(barcode: string): Promise { + async findByAssetProfileBarcode(barcode: string): Promise { return this.prisma.productSKU.findFirst({ where: { deletedAt: null, - attributes: { - path: ['assetSkuSnapshot', 'barcode'], - equals: barcode, - }, + assetProfile: { is: { barcode: normalizeCoreIdentity(barcode) } }, }, }) } @@ -69,7 +67,7 @@ class SKURepositoryClass extends BaseRepository< return client.productSKU.findFirst({ where: { productId, - skuCode, + skuCode: normalizeCoreIdentity(skuCode), }, }) } @@ -86,7 +84,7 @@ class SKURepositoryClass extends BaseRepository< const count = await this.prisma.productSKU.count({ where: { - skuCode, + skuCode: normalizeCoreIdentity(skuCode), deletedAt: includeDeleted ? undefined : null, id: excludeId ? { not: excludeId } : undefined, }, @@ -121,6 +119,7 @@ class SKURepositoryClass extends BaseRepository< product: { select: { name: true, modelNumber: true }, }, + assetProfile: true, }, }), this.prisma.productSKU.count({ where }), @@ -148,6 +147,7 @@ class SKURepositoryClass extends BaseRepository< modelUrl: true, }, }, + assetProfile: true, }, }) } @@ -177,6 +177,7 @@ class SKURepositoryClass extends BaseRepository< warehouseLocation: { select: { id: true, code: true, name: true }, }, + assetProfile: true, }, }) } @@ -194,6 +195,7 @@ class SKURepositoryClass extends BaseRepository< owner: { select: { id: true, name: true, avatar: true }, }, + assetProfile: true, }, orderBy: { createdAt: 'asc' }, }) @@ -401,6 +403,7 @@ class SKURepositoryClass extends BaseRepository< if (filter.search) { where.OR = [ { skuCode: { contains: filter.search, mode: 'insensitive' } }, + { name: { contains: filter.search, mode: 'insensitive' } }, ] } diff --git a/furniture-3d-system/src/server/repositories/warehouse.repository.ts b/furniture-3d-system/src/server/repositories/warehouse.repository.ts index 71c008a5..4ce90573 100644 --- a/furniture-3d-system/src/server/repositories/warehouse.repository.ts +++ b/furniture-3d-system/src/server/repositories/warehouse.repository.ts @@ -1,4 +1,5 @@ import { Prisma, WarehouseLocation } from '@prisma/client' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { BaseRepository } from './base.repository' class WarehouseLocationRepositoryClass extends BaseRepository< @@ -9,12 +10,25 @@ class WarehouseLocationRepositoryClass extends BaseRepository< protected modelName = 'warehouseLocation' async findByCode(code: string): Promise { - return this.prisma.warehouseLocation.findUnique({ where: { code } }) + return this.prisma.warehouseLocation.findUnique({ where: { code: normalizeCoreIdentity(code) } }) } - async findByPosition(zone: string | null, aisle: string | null, shelf: string | null, bin: string | null): Promise { + async findByPosition( + zone: string | null, + aisle: string | null, + shelf: string | null, + bin: string | null, + excludeId?: string, + ): Promise { return this.prisma.warehouseLocation.findFirst({ - where: { zone, aisle, shelf, bin, isActive: true }, + where: { + zone, + aisle, + shelf, + bin, + isActive: true, + ...(excludeId ? { id: { not: excludeId } } : {}), + }, }) } diff --git a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts index 3ae80be6..1dfb1a13 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts @@ -377,6 +377,86 @@ describe('assetCadImportService', () => { expect(result.similarityNotice).toBeNull() }) + it('板材导入会在查询已有资产族前归一化手填编码', async () => { + const family = createFamilyDTO({ + id: 'family-board-top', + type: 'BOARD', + code: 'DBZ-SPJK001', + name: '桌面板', + }) + const variant = createVariantDTO({ + id: 'variant-board-1200', + assetFamilyId: family.id, + assetFamilyCode: family.code, + assetFamilyName: family.name, + assetType: 'BOARD', + code: 'DBZ-SPJ00001', + name: '桌面板 1200', + }) + + mockedAssetFamilyRepository.findByCode.mockResolvedValue({ + id: family.id, + type: 'BOARD', + code: family.code, + name: family.name, + } as never) + mockedAssetCenterService.listFamilies.mockResolvedValue([family] as never) + mockedAssetCenterService.createVariantWithSimilarity.mockResolvedValue({ + variant, + similarCandidates: [], + similarityNotice: null, + } as never) + mockedParseZipFile.mockResolvedValue({ + success: true, + modelFile: new File(['glb'], 'desktop.glb', { type: 'model/gltf-binary' }), + cadExportData: { + exportedAt: '2026-04-03T08:00:00.000Z', + sourceFile: 'desktop.dwg', + software: 'AutoCAD', + pluginVersion: '2.2.15', + modelFile: 'desktop.glb', + panelCount: 1, + units: 'mm', + panels: [ + { + id: 'panel-1', + sourceHandle: 'handle-1', + position: [0, 0, 0], + size: [1200, 600, 25], + normal: [0, 0, 1], + panelType: 'top_panel', + }, + ], + holes: [], + hardwareTypes: [], + hardwareSummary: [], + } as never, + }) + + await assetCadImportService.importAsset({ + assetType: 'BOARD', + familyCode: ' dbz-spjk001 ', + familyName: '桌面板', + variantCode: ' dbz-spj00001 ', + variantName: '桌面板 1200', + substrateId: 'substrate-board', + finishId: 'finish-oak', + zipFile: createZipFile('board.zip'), + createdBy: 'user-1', + }) + + expect(mockedAssetFamilyRepository.findByCode).toHaveBeenCalledWith('DBZ-SPJK001') + expect(mockedAssetCenterService.createFamily).not.toHaveBeenCalled() + expect(mockedUploadToS3).toHaveBeenCalledWith( + expect.any(File), + 'asset-center/board/DBZ-SPJK001/DBZ-SPJ00001-1710000000000.glb', + ) + expect(mockedAssetCenterService.createVariantWithSimilarity.mock.calls[0][0]).toMatchObject({ + assetFamilyId: 'family-board-top', + code: 'DBZ-SPJ00001', + }) + }) + it('板材导入未传饰面时,会从基材绑定花色自动回填', async () => { mockedAssetCenterService.listSubstrates.mockResolvedValue([ { diff --git a/furniture-3d-system/src/server/services/__tests__/asset-sku-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-sku-sync.service.test.ts index 2f5a0a91..44769f2b 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-sku-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-sku-sync.service.test.ts @@ -96,9 +96,9 @@ describe('AssetSkuSyncService', () => { assetSeries: { id: 'series-dbz', code: 'DBZ', name: '单板桌' }, deletedAt: null, }, - code: 'DBZ-SPJ00002', + code: ' dbz-spj00002 ', name: '桌面板新规格', - barcode: 'DBZ-SPJ00002N05V01', + barcode: ' dbz-spj00002n05v01 ', dimensions: { length: 1200, width: 600, thickness: 25 }, thumbnailUrl: '/new-thumb.webp', finish: { id: 'finish-1', name: '柚木色' }, @@ -122,22 +122,42 @@ describe('AssetSkuSyncService', () => { modelName: '桌面板', specCode: 'DBZ-SPJ00001', specName: '桌面板旧规格', - barcode: 'MANUAL-BARCODE', + barcode: 'STALE-BARCODE', size: { length: 1000 }, color: '手工颜色', material: '手工材质', - imageUrl: '/manual-thumb.webp', + imageUrl: '/stale-thumb.webp', sourceStatus: 'ACTIVE', sourceAssetVariantIds: ['variant-1'], sourceVariants: [], syncedAt: '2026-05-18T00:00:00.000Z', - lockedFields: ['barcode', 'imageUrl'], - lockedReasons: { - barcode: '人工调整', - imageUrl: '人工调整', - }, + lockedFields: [], + lockedReasons: {}, }, }, + assetProfile: { + primarySourceAssetVariantId: 'variant-1', + sourceAssetVariantIds: ['variant-1'], + sourceAssetFamilyId: 'family-1', + sourceAssetSeriesId: 'series-dbz', + model: 'DBZ-SPJK001', + modelName: '桌面板', + specCode: 'DBZ-SPJ00001', + specName: '桌面板旧规格', + barcode: ' manual-barcode ', + size: { length: 1000 }, + color: '手工颜色', + material: '手工材质', + imageUrl: '/manual-thumb.webp', + sourceStatus: 'ACTIVE', + syncedAt: new Date('2026-05-18T00:00:00.000Z'), + lockedFields: ['barcode', 'imageUrl'], + lockedReasons: { + barcode: '人工调整', + imageUrl: '人工调整', + }, + sourceVariants: [], + }, }, ] as never) mockedPrisma.assetSkuSyncEvent.update.mockResolvedValue({} as never) @@ -154,8 +174,25 @@ describe('AssetSkuSyncService', () => { barcode: 'MANUAL-BARCODE', imageUrl: '/manual-thumb.webp', specCode: 'DBZ-SPJ00002', + model: 'DBZ-SPJK001', }), }), + assetProfile: { + upsert: { + create: expect.objectContaining({ + barcode: 'MANUAL-BARCODE', + imageUrl: '/manual-thumb.webp', + specCode: 'DBZ-SPJ00002', + model: 'DBZ-SPJK001', + }), + update: expect.objectContaining({ + barcode: 'MANUAL-BARCODE', + imageUrl: '/manual-thumb.webp', + specCode: 'DBZ-SPJ00002', + model: 'DBZ-SPJK001', + }), + }, + }, }), }), ) @@ -216,6 +253,42 @@ describe('AssetSkuSyncService', () => { lockedReasons: {}, }, }, + assetProfile: { + primarySourceAssetVariantId: 'variant-missing', + sourceAssetVariantIds: ['variant-missing'], + sourceAssetFamilyId: 'family-old', + sourceAssetSeriesId: 'series-dbz', + model: 'DBZ-SPJK001', + modelName: '旧桌面板', + specCode: 'DBZ-SPJ00001', + specName: '旧规格', + barcode: 'DBZ-SPJ00001N05V01', + size: { length: 1200 }, + color: '柚木色', + material: '颗粒板', + imageUrl: '/old-thumb.webp', + sourceStatus: 'ACTIVE', + syncedAt: new Date('2026-05-18T00:00:00.000Z'), + lockedFields: [], + lockedReasons: {}, + sourceVariants: [ + { + sourceAssetVariantId: 'variant-missing', + sourceAssetFamilyId: 'family-old', + sourceAssetSeriesId: 'series-dbz', + model: 'DBZ-SPJK001', + modelName: '旧桌面板', + specCode: 'DBZ-SPJ00001', + specName: '旧规格', + barcode: 'DBZ-SPJ00001N05V01', + size: { length: 1200 }, + color: '柚木色', + material: '颗粒板', + imageUrl: '/old-thumb.webp', + sourceStatus: 'ACTIVE', + }, + ], + }, }, ] as never) mockedPrisma.assetSkuSyncEvent.update.mockResolvedValue({} as never) @@ -238,6 +311,18 @@ describe('AssetSkuSyncService', () => { ], }), }), + assetProfile: { + upsert: { + create: expect.objectContaining({ + sourceStatus: 'MISSING', + specCode: 'DBZ-SPJ00001', + }), + update: expect.objectContaining({ + sourceStatus: 'MISSING', + specCode: 'DBZ-SPJ00001', + }), + }, + }, }), }), ) diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts index a5c48ee5..24e02c3b 100644 --- a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts @@ -1,5 +1,6 @@ import { draftGroupService } from '../draft-group.service' import { preSkuPackagingService } from '../pre-sku-packaging.service' +import { ErrorCode } from '@/server/core/errors' import { assetFamilyRepository, assetVariantRepository, @@ -47,15 +48,16 @@ jest.mock('../../repositories', () => ({ findByDraftGroupId: jest.fn(), findByIdWithRelations: jest.fn(), updateDisplaySummary: jest.fn(), - findByDisplaySummaryField: jest.fn(), - findByDisplaySummaryFields: jest.fn(), + upsertBasicInfo: jest.fn(), + findByBasicInfoField: jest.fn(), + findByBasicInfoFields: jest.fn(), }, preSkuPackagingPlanRepository: { upsertByPreSkuId: jest.fn(), }, skuRepository: { findBySkuCode: jest.fn(), - findByAssetSnapshotBarcode: jest.fn(), + findByAssetProfileBarcode: jest.fn(), }, })) @@ -268,10 +270,10 @@ describe('draftGroupService', () => { mockedAssetVariantRepository.findByCode.mockResolvedValue(null) mockedAssetVariantRepository.findByBarcode.mockResolvedValue(null) mockedProductRepository.findByModelNumber.mockResolvedValue(null) - mockedPreSkuRepository.findByDisplaySummaryField.mockResolvedValue([]) - mockedPreSkuRepository.findByDisplaySummaryFields.mockResolvedValue([]) + mockedPreSkuRepository.findByBasicInfoField.mockResolvedValue([]) + mockedPreSkuRepository.findByBasicInfoFields.mockResolvedValue([]) mockedSkuRepository.findBySkuCode.mockResolvedValue(null) - mockedSkuRepository.findByAssetSnapshotBarcode.mockResolvedValue(null) + mockedSkuRepository.findByAssetProfileBarcode.mockResolvedValue(null) mockedAssetFamilyRepository.findByIdsWithRelations.mockResolvedValue([ { @@ -657,7 +659,12 @@ describe('draftGroupService', () => { authUser, ) - expect(mockedDraftGroupRepository.createWithRelations).toHaveBeenCalled() + expect(mockedDraftGroupRepository.createWithRelations).toHaveBeenCalledWith( + expect.objectContaining({ + code: expect.stringMatching(/^DG-\d{14}-[A-Z0-9_-]{6}$/), + }), + expect.anything(), + ) expect(mockedPreSkuRepository.createManyForDraftGroup).toHaveBeenCalledWith( 'draft-group-1', expect.arrayContaining([ @@ -1218,7 +1225,75 @@ describe('draftGroupService', () => { ) }) - it('保存准 SKU 基础资料时只合并到 displaySummary 草稿快照', async () => { + it('读取草稿组详情时将准 SKU 基础资料作为 basicInfo 返回,不再伪装进 displaySummary', async () => { + mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([ + { + id: 'pre-sku-basic-info', + draftGroupId: 'draft-group-1', + sequence: 1, + defaultName: '云序-悬浮桌面 A-细腿', + customName: null, + status: 'DRAFT', + sourceCandidateKey: 'candidate-1', + familySelection: { + model: 'LEGACY-MODEL', + }, + variantSelection: { + slots: [], + hardwareSelections: [], + }, + recommendedHardware: [], + previewNodes: [], + basicInfo: { + model: 'DBZ-SPJK001', + formalSkuCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + size: '1200*600*750', + color: '柚木色', + material: '颗粒板', + specName: '1200 柚木色', + versionNo: 'V01', + setCount: '1', + attributeCode: '', + drawingId: '', + installMethod: '', + manualUpdatedAt: new Date('2026-05-30T10:00:00.000Z'), + manualUpdatedBy: 'user-1', + manualUpdatedByName: '规划员', + }, + displaySummary: { + resultStatus: 'ASSEMBLABLE', + formalSkuCode: 'LEGACY-SKU', + barcode: 'LEGACY-BARCODE', + color: '旧花色', + }, + bomPreview: [], + weightPreview: null, + componentCodes: [], + installStepPreview: [], + notes: null, + createdAt: new Date('2026-03-30T12:00:00.000Z'), + updatedAt: new Date('2026-03-30T12:00:00.000Z'), + packagingPlan: null, + }, + ] as never) + + const result = await draftGroupService.getById('draft-group-1') + + expect(result.preSkus[0].basicInfo).toEqual( + expect.objectContaining({ + model: 'DBZ-SPJK001', + formalSkuCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + manualUpdatedAt: '2026-05-30T10:00:00.000Z', + }), + ) + expect(result.preSkus[0].displaySummary).toEqual({ + resultStatus: 'ASSEMBLABLE', + }) + }) + + it('保存准 SKU 基础资料时返回结构化 basicInfo,并清理 displaySummary 旧基础资料字段', async () => { const basePreSku = { id: 'pre-sku-1', draftGroupId: 'draft-group-1', @@ -1237,8 +1312,12 @@ describe('draftGroupService', () => { recommendedHardware: [], previewNodes: [], displaySummary: { + resultStatus: 'ASSEMBLABLE', formalSkuCode: 'OLD-SKU', + barcode: 'OLD-BARCODE', color: '旧花色', + basicInfoManualUpdatedAt: '2026-05-29T01:00:00.000Z', + basicInfoManualUpdatedBy: 'legacy-user', }, bomPreview: [], weightPreview: null, @@ -1256,13 +1335,32 @@ describe('draftGroupService', () => { displaySummary, }) as never, ) + mockedPreSkuRepository.upsertBasicInfo.mockImplementation(async (_preSkuId, data) => + ({ + ...basePreSku, + basicInfo: { + id: 'basic-info-1', + preSkuId: 'pre-sku-1', + ...data, + }, + displaySummary: { + ...basePreSku.displaySummary, + model: data.model, + formalSkuCode: data.formalSkuCode, + barcode: data.barcode, + versionNo: data.versionNo, + }, + }) as never, + ) const result = await draftGroupService.updatePreSkuBasicInfo( 'pre-sku-1', { fields: { - model: 'DBZ-SPJK001', - formalSkuCode: 'DBZ-SPJ00001', + model: 'dbz-spjk001', + formalSkuCode: 'dbz-spj00001', + barcode: 'dbz-spj00001n05v01', + versionNo: 'v01', color: '', }, }, @@ -1273,23 +1371,157 @@ describe('draftGroupService', () => { | Record | undefined - expect(savedSummary).toEqual( + expect(savedSummary).toEqual({ + resultStatus: 'ASSEMBLABLE', + }) + expect(mockedPreSkuRepository.upsertBasicInfo).toHaveBeenCalledWith( + 'pre-sku-1', expect.objectContaining({ model: 'DBZ-SPJK001', formalSkuCode: 'DBZ-SPJ00001', - basicInfoManualUpdatedBy: 'user-1', - basicInfoManualUpdatedByName: '规划员', + barcode: 'DBZ-SPJ00001N05V01', + versionNo: 'V01', + color: null, + manualUpdatedBy: 'user-1', + manualUpdatedByName: '规划员', + source: 'MANUAL', }), ) - expect(savedSummary).not.toHaveProperty('color') - expect(result.displaySummary).toEqual( + expect(mockedProductRepository.findByModelNumber).toHaveBeenCalledWith('DBZ-SPJK001') + expect(mockedAssetFamilyRepository.findByCode).toHaveBeenCalledWith('DBZ-SPJK001') + expect(mockedAssetVariantRepository.findByCode).toHaveBeenCalledWith('DBZ-SPJ00001') + expect(mockedSkuRepository.findBySkuCode).toHaveBeenCalledWith('DBZ-SPJ00001') + expect(mockedAssetVariantRepository.findByBarcode).toHaveBeenCalledWith('DBZ-SPJ00001N05V01') + expect(mockedSkuRepository.findByAssetProfileBarcode).toHaveBeenCalledWith( + 'DBZ-SPJ00001N05V01', + ) + expect(result.displaySummary).not.toHaveProperty('model') + expect(result.displaySummary).not.toHaveProperty('formalSkuCode') + expect(result.displaySummary).not.toHaveProperty('barcode') + expect(result.displaySummary).not.toHaveProperty('versionNo') + expect(result.displaySummary).not.toHaveProperty('color') + expect(result.basicInfo).toEqual( expect.objectContaining({ model: 'DBZ-SPJK001', formalSkuCode: 'DBZ-SPJ00001', + color: '', + manualUpdatedBy: 'user-1', + manualUpdatedByName: '规划员', }), ) }) + it('保存准 SKU 基础资料时不会把旧 displaySummary 或 familySelection 提升为结构化主数据', async () => { + const legacyPreSku = { + id: 'pre-sku-legacy', + draftGroupId: 'draft-group-1', + sequence: 1, + defaultName: '旧快照准 SKU', + customName: null, + status: 'DRAFT', + sourceCandidateKey: 'candidate-legacy', + familySelection: { + model: 'LEGACY-FAMILY-MODEL', + formalSkuCode: 'LEGACY-FAMILY-SKU', + }, + variantSelection: { + slots: [], + hardwareSelections: [], + }, + recommendedHardware: [], + previewNodes: [], + displaySummary: { + resultStatus: 'ASSEMBLABLE', + model: 'LEGACY-MODEL', + formalSkuCode: 'LEGACY-SKU', + barcode: 'LEGACY-BARCODE', + specName: '旧规格名', + color: '旧花色', + }, + bomPreview: [], + weightPreview: null, + componentCodes: [], + installStepPreview: [], + notes: null, + createdAt: new Date('2026-03-30T12:00:00.000Z'), + updatedAt: new Date('2026-03-30T12:00:00.000Z'), + packagingPlan: null, + basicInfo: null, + } + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(legacyPreSku as never) + mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([]) + mockedPreSkuRepository.updateDisplaySummary.mockResolvedValue({ + ...legacyPreSku, + displaySummary: { resultStatus: 'ASSEMBLABLE' }, + } as never) + mockedPreSkuRepository.upsertBasicInfo.mockImplementation(async (_preSkuId, data) => + ({ + ...legacyPreSku, + displaySummary: { resultStatus: 'ASSEMBLABLE' }, + basicInfo: { + id: 'basic-info-legacy', + preSkuId: 'pre-sku-legacy', + ...data, + }, + }) as never, + ) + + await draftGroupService.updatePreSkuBasicInfo( + 'pre-sku-legacy', + { + fields: { + color: '新花色', + }, + }, + authUser, + ) + + expect(mockedPreSkuRepository.updateDisplaySummary).toHaveBeenCalledWith( + 'pre-sku-legacy', + { + resultStatus: 'ASSEMBLABLE', + }, + ) + expect(mockedPreSkuRepository.upsertBasicInfo).toHaveBeenCalledWith( + 'pre-sku-legacy', + expect.objectContaining({ + model: null, + formalSkuCode: null, + barcode: null, + specName: null, + color: '新花色', + versionNo: 'V01', + source: 'MANUAL', + }), + ) + expect(mockedProductRepository.findByModelNumber).not.toHaveBeenCalledWith('LEGACY-MODEL') + expect(mockedAssetVariantRepository.findByCode).not.toHaveBeenCalledWith('LEGACY-SKU') + expect(mockedAssetVariantRepository.findByBarcode).not.toHaveBeenCalledWith('LEGACY-BARCODE') + }) + + it('保存准 SKU 基础资料时会阻断结构化规格码或条码重复', async () => { + mockedPreSkuRepository.findByBasicInfoField.mockImplementation(async (field) => { + if (field === 'formalSkuCode') { + return [{ id: 'pre-sku-2', defaultName: '重复规格款', customName: null }] + } + return [] + }) + + await expect( + draftGroupService.updatePreSkuBasicInfo( + 'pre-sku-1', + { + fields: { + formalSkuCode: 'dbz-spj00001', + }, + }, + authUser, + ), + ).rejects.toThrow('已被其他准 SKU 使用,不能保存') + + expect(mockedPreSkuRepository.upsertBasicInfo).not.toHaveBeenCalled() + }) + it('可以保存草稿组品类', async () => { mockedDraftGroupRepository.updateWithRelations.mockResolvedValue({ id: 'draft-group-1', @@ -1352,7 +1584,9 @@ describe('draftGroupService', () => { customName: null, status: 'DRAFT', sourceCandidateKey: 'candidate-1', - familySelection: {}, + familySelection: { + model: 'LEGACY-FAMILY-MODEL', + }, variantSelection: { slots: [ { @@ -1375,7 +1609,11 @@ describe('draftGroupService', () => { }, recommendedHardware: [], previewNodes: [], - displaySummary: {}, + displaySummary: { + model: 'LEGACY-MODEL', + formalSkuCode: 'LEGACY-SKU', + barcode: 'LEGACY-BARCODE', + }, bomPreview: [], weightPreview: null, componentCodes: [], @@ -1455,7 +1693,7 @@ describe('draftGroupService', () => { lastModifier: null, }, ] as never) - mockedPreSkuRepository.findByDisplaySummaryField.mockResolvedValue([ + mockedPreSkuRepository.findByBasicInfoField.mockResolvedValue([ { id: 'pre-sku-2', defaultName: '重复准 SKU', customName: null }, ]) @@ -1474,6 +1712,9 @@ describe('draftGroupService', () => { ) expect(metadata.options.color?.map((option) => option.value)).toContain('柚木色') expect(metadata.conflicts.model?.[0]?.message).toContain('重复准 SKU') + expect(mockedProductRepository.findByModelNumber).not.toHaveBeenCalledWith('LEGACY-MODEL') + expect(mockedAssetVariantRepository.findByCode).not.toHaveBeenCalledWith('LEGACY-SKU') + expect(mockedAssetVariantRepository.findByBarcode).not.toHaveBeenCalledWith('LEGACY-BARCODE') }) it('保存新包装结构后会把草稿组状态推进到 PACKAGING', async () => { @@ -1543,6 +1784,66 @@ describe('draftGroupService', () => { expect(result.boxes).toHaveLength(1) }) + it('保存包装方案会在服务层拒绝无效箱规和游离状态', async () => { + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue({ + id: 'pre-sku-1', + draftGroupId: 'draft-group-1', + defaultName: '云序桌-标准版', + customName: null, + sourceCandidateKey: 'family-desktop-a::family-leg-slim', + variantSelection: { + slots: [], + hardwareSelections: [], + }, + bomPreview: [], + weightPreview: null, + packagingPlan: null, + } as never) + + await expect( + preSkuPackagingService.savePackagingPlan('pre-sku-1', { + status: 'BROKEN', + boxes: [ + { + boxNumber: 1, + name: '主箱', + outerSizeMm: { length: 1200, width: 650, height: 90 }, + items: [], + materials: [], + estimatedWeightKg: 16.1, + estimatedCost: 12, + }, + ], + costSummary: null, + }), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: '包装方案状态不在允许范围内', + }) + + await expect( + preSkuPackagingService.savePackagingPlan('pre-sku-1', { + boxes: [ + { + boxNumber: 1, + name: '主箱', + outerSizeMm: { length: 0, width: 650, height: 90 }, + items: [], + materials: [], + estimatedWeightKg: 16.1, + estimatedCost: 12, + }, + ], + costSummary: null, + }), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: '第 1 个包装箱长度必须大于 0', + }) + + expect(mockedPreSkuPackagingPlanRepository.upsertByPreSkuId).not.toHaveBeenCalled() + }) + it('保存包装方案未传 processDraft 时会保留已有工艺草稿和备注', async () => { mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue({ id: 'pre-sku-1', diff --git a/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts index 89bf5fd3..5a2073ad 100644 --- a/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts @@ -31,9 +31,9 @@ describe('employee-directory-sync.service', () => { await employeeDirectorySyncService.sync(payload) expect(mockUpsertMany).toHaveBeenCalledWith({ - departments: payload.departments, - employees: payload.employees, - dimissionEntries: payload.dimissions, + departments: [expect.objectContaining({ externalId: 'dept-1', name: '设计部' })], + employees: [expect.objectContaining({ name: '张三' })], + dimissionEntries: [expect.objectContaining({ mobile: '13800000000' })], }) }) @@ -49,4 +49,42 @@ describe('employee-directory-sync.service', () => { message: '部门同步数据缺少必要字段', } as Partial) }) + + it('同步前会规范化部门、员工和离职身份字段', async () => { + await employeeDirectorySyncService.sync({ + departments: [{ externalId: ' dept-1 ', name: ' 供应链中心 ', parentExternalId: ' parent-1 ' }], + employees: [ + { + dingtalkUserId: ' dt-1 ', + dingtalkUnionId: ' union-1 ', + jobNumber: ' JS1001 ', + name: ' 采购甲 ', + mobile: ' 13800000000 ', + email: ' Buyer@Example.COM ', + title: ' 采购 ', + primaryDepartmentExternalId: ' dept-1 ', + departmentExternalIds: [' dept-1 ', 'dept-1', ' dept-2 '], + }, + ], + dimissions: [{ mobile: ' 13900000000 ', jobNumber: ' JS0999 ' }], + }) + + expect(mockUpsertMany).toHaveBeenCalledWith({ + departments: [{ externalId: 'dept-1', name: '供应链中心', parentExternalId: 'parent-1' }], + employees: [ + expect.objectContaining({ + dingtalkUserId: 'dt-1', + dingtalkUnionId: 'union-1', + jobNumber: 'JS1001', + name: '采购甲', + mobile: '13800000000', + email: 'buyer@example.com', + title: '采购', + primaryDepartmentExternalId: 'dept-1', + departmentExternalIds: ['dept-1', 'dept-2'], + }), + ], + dimissionEntries: [expect.objectContaining({ mobile: '13900000000', jobNumber: 'JS0999' })], + }) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index f6cef2c2..23b78d74 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -1,15 +1,34 @@ import { createDefaultDocumentWorkspaceConfirmations } from '@/app/intake/non-integrated/nonIntegratedStorage' +var mockTransactionExistingRecord: unknown = null + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $transaction: jest.fn((callback) => + callback({ + intakeNonIntegratedProduct: { + findUnique: jest.fn().mockImplementation(() => Promise.resolve(mockTransactionExistingRecord)), + }, + }), + ), + }, +})) + jest.mock('../../repositories/intake-non-integrated-product.repository', () => ({ intakeNonIntegratedProductRepository: { listActive: jest.fn(), + listActivePage: jest.fn(), findActiveById: jest.fn(), findById: jest.fn(), findBySpu: jest.fn(), + findBySpuInTransaction: jest.fn(), findDeletedBySpu: jest.fn(), + findDeletedBySpuInTransaction: jest.fn(), create: jest.fn(), + createInTransaction: jest.fn(), update: jest.fn(), - updatePayloadByUser: jest.fn(), + updateInTransaction: jest.fn(), + updateAttachmentPayloadByUser: jest.fn(), softDeleteByUser: jest.fn(), }, })) @@ -26,6 +45,12 @@ jest.mock('../../repositories/draft-group.repository', () => ({ }, })) +jest.mock('../spu-number-reservation.service', () => ({ + spuNumberReservationService: { + reserveInTransaction: jest.fn(), + }, +})) + jest.mock('../intake-node-permission-settings.service', () => ({ intakeNodePermissionSettingsService: { getSettings: jest.fn().mockResolvedValue({ overrides: {} }), @@ -42,11 +67,25 @@ jest.mock('../sku-quality-inspection.service', () => ({ import { intakeNonIntegratedProductRepository } from '../../repositories/intake-non-integrated-product.repository' import { draftGroupRepository, preSkuRepository } from '../../repositories/draft-group.repository' -import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service' +import { prisma } from '@/lib/prisma' +import { ErrorCode } from '@/server/core/errors' +import { + buildStructuredSkuItemInputs, + intakeNonIntegratedProductService, +} from '../intake-non-integrated-product.service' +import { spuNumberReservationService } from '../spu-number-reservation.service' +import { + assertNonIntegratedProductMasterDataReadyForFormalUse, + assertNonIntegratedSkuItemsReadyForFormalUse, + resolveNonIntegratedProductMissingRequiredFields, + resolveNonIntegratedSkuQuality, +} from '../non-integrated-sku-quality' const mockedRepository = jest.mocked(intakeNonIntegratedProductRepository) const mockedDraftGroupRepository = jest.mocked(draftGroupRepository) const mockedPreSkuRepository = jest.mocked(preSkuRepository) +const mockedSpuNumberReservationService = jest.mocked(spuNumberReservationService) +const mockedPrisma = jest.mocked(prisma) const baseUser = { id: 'creator-1', @@ -90,15 +129,17 @@ function createPayload(overrides: Record = {}) { } function createRecord(overrides: Record = {}) { + const payload = createPayload(overrides.payload as Record | undefined) + return { id: 'non-integrated-1', - spu: 'DNZ0001', - spuBase: 'DNZ0001', - brand: 'Base', - designer: 'Designer', - operator: 'Operator', - purchaser: 'Purchaser', - payload: createPayload(overrides.payload as Record | undefined), + spu: typeof payload.spu === 'string' ? payload.spu : 'DNZ0001', + spuBase: typeof payload.spu === 'string' ? payload.spu : 'DNZ0001', + brand: typeof payload.brand === 'string' ? payload.brand : 'Base', + designer: typeof payload.designer === 'string' ? payload.designer : 'Designer', + operator: typeof payload.operator === 'string' ? payload.operator : 'Operator', + purchaser: typeof payload.purchaser === 'string' ? payload.purchaser : 'Purchaser', + payload, visibility: 'DEPARTMENT', departmentId: 'dept-1', teamId: 'team-1', @@ -111,8 +152,46 @@ function createRecord(overrides: Record = {}) { } } +function createSkuItem(overrides: Record = {}) { + return { + id: 'sku-1', + skuSequence: 1, + batchNo: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'MODEL-001', + skuCode: 'SKU-001', + setCount: '1', + specName: 'Default spec', + barcode: 'BAR-001', + size: '1200*600*750', + color: 'teak', + material: 'MDF', + boardMaterialType: '', + versionNo: 'V01', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + nationalStandardCompliant: '', + manufacturer: 'Factory', + manufacturerCode: 'N01', + salesStatus: '', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + source: '', + sourceTable: '', + sourceSkuId: '', + ...overrides, + } +} + describe('intakeNonIntegratedProductService', () => { beforeEach(() => { + mockTransactionExistingRecord = null jest.clearAllMocks() mockedDraftGroupRepository.countPackagedItems.mockResolvedValue(0) mockedDraftGroupRepository.countPreSkuLocationBindings.mockResolvedValue(0) @@ -130,6 +209,295 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.listActive).toHaveBeenCalledWith(undefined) }) + it('读取列表时用结构化 profile 和 SKU 明细覆盖 payload 主数据', async () => { + mockedRepository.listActive.mockResolvedValue([ + createRecord({ + payload: createPayload({ + spu: 'PAYLOAD-OLD-SPU', + brand: 'payload 旧品牌', + designer: 'payload 旧设计', + operator: 'payload 旧运营', + purchaser: 'payload 旧采购', + openingReference: { + productName: 'payload 旧产品名', + productImage: 'payload-old.jpg', + productCategory: 'payload 旧品类', + }, + previewImage: 'payload-preview.jpg', + skuItems: [ + createSkuItem({ + id: 'sku-1', + skuCode: 'PAYLOAD-SKU', + specName: 'payload 旧规格', + barcode: 'PAYLOAD-BAR', + }), + ], + }), + profile: { + productName: '结构化产品名', + productImageUrl: 'structured-image.jpg', + productCategory: '结构化品类', + productDevelopmentKind: 'product', + productStatus: '存量商品', + purchaseOwner: '采购负责人', + sourceSpu: 'SPU-STRUCTURED', + sourceSkuId: 'BAR-STRUCTURED', + sourceCategory: '结构化分类', + sourceItemType: '成品', + sourceSalesStatus: '销售', + sourceEnabled: '1', + sourceStockDisabled: '0', + }, + skuItems: [ + { + skuItemId: 'sku-1', + sortOrder: 1, + skuSequence: 9, + batchNo: 2, + packageGroupId: 'pkg-1', + image: 'structured-sku.jpg', + imageName: 'structured-sku.jpg', + model: 'MODEL-STRUCTURED', + skuCode: 'STRUCTURED-SKU', + specName: '结构化规格', + barcode: 'STRUCTURED-BAR', + size: '1200*600*750', + color: '柚木色', + material: '颗粒板', + boardMaterialType: '板材', + versionNo: 'V02', + attributeCode: 'ATTR-1', + drawingId: 'DRAW-1', + installMethod: '螺丝安装', + executeStandard: '企业标准', + nationalStandardCompliant: '是', + manufacturer: '工厂A', + manufacturerCode: 'N05', + salesStatus: '销售', + tagMaterial: '标签', + model3dDataUrl: 'structured-model.glb', + source: 'jst_sku', + sourceTable: 'jst_sku', + sourceSkuId: 'source-1', + contentLocked: true, + }, + ], + spu: 'STRUCTURED-SPU-COLUMN', + spuBase: 'STRUCTURED-SPU-COLUMN', + brand: '结构化品牌列', + designer: '结构化设计列', + operator: '结构化运营列', + purchaser: '结构化采购列', + }), + ] as never) + + const products = await intakeNonIntegratedProductService.list(baseUser) + + expect(products[0]).toEqual( + expect.objectContaining({ + spu: 'STRUCTURED-SPU-COLUMN', + productCode: 'STRUCTURED-SPU-COLUMN', + brand: '结构化品牌列', + designer: '结构化设计列', + operator: '结构化运营列', + purchaser: '结构化采购列', + }), + ) + expect(products[0]?.openingReference.productName).toBe('结构化产品名') + expect(products[0]?.openingReference.productImage).toBe('structured-image.jpg') + expect(products[0]?.openingReference.productCategory).toBe('结构化品类') + expect(products[0]?.previewImage).toBe('structured-image.jpg') + expect(products[0]?.purchaseOwner).toBe('采购负责人') + expect(products[0]?.metadata).toEqual( + expect.objectContaining({ + sourceSpu: 'SPU-STRUCTURED', + sourceSkuId: 'BAR-STRUCTURED', + sourceCategory: '结构化分类', + sourceItemType: '成品', + sourceSalesStatus: '销售', + sourceEnabled: '1', + }), + ) + expect(products[0]?.skuItems[0]).toEqual( + expect.objectContaining({ + id: 'sku-1', + skuSequence: 9, + batchNo: 2, + skuCode: 'STRUCTURED-SKU', + specName: '结构化规格', + barcode: 'STRUCTURED-BAR', + model: 'MODEL-STRUCTURED', + color: '柚木色', + material: '颗粒板', + contentLocked: true, + }), + ) + }) + + it('结构化 profile 已存在时不会用旧 payload 补齐缺失的产品头主数据', async () => { + mockedRepository.listActive.mockResolvedValue([ + createRecord({ + payload: createPayload({ + productCategory: 'payload 旧品类', + productDevelopmentKind: 'series', + previewImage: 'payload-preview.jpg', + metadata: { + sourceSpu: 'PAYLOAD-SPU', + sourceSkuId: 'PAYLOAD-SKU', + }, + openingReference: { + productName: 'payload 旧产品名', + productImage: 'payload-old.jpg', + productCategory: 'payload 旧品类', + }, + }), + profile: { + productName: '结构化产品名', + productImageUrl: '', + productCategory: '', + productDevelopmentKind: '', + sourceSpu: '', + sourceSkuId: '', + }, + skuItems: [], + }), + ] as never) + + const products = await intakeNonIntegratedProductService.list(baseUser) + + expect(products[0]?.openingReference.productName).toBe('结构化产品名') + expect(products[0]?.openingReference.productImage).toBe('') + expect(products[0]?.openingReference.productCategory).toBe('') + expect(products[0]?.previewImage).toBe('') + expect(products[0]?.productCategory).toBe('') + expect(products[0]?.productDevelopmentKind).toBe('') + expect(products[0]?.metadata).not.toEqual( + expect.objectContaining({ + sourceSpu: 'PAYLOAD-SPU', + sourceSkuId: 'PAYLOAD-SKU', + }), + ) + }) + + it('分页筛选存量商品时优先使用结构化来源字段', async () => { + mockedRepository.listActivePage.mockResolvedValue({ + records: [ + createRecord({ + profile: { + sourceSpu: 'SPU001', + sourceSkuId: 'BAR001', + sourceCategory: '餐桌', + sourceItemType: '成品', + sourceSalesStatus: '销售', + sourceEnabled: '1', + }, + }), + ], + total: 1, + } as never) + + const result = await intakeNonIntegratedProductService.listPage(baseUser, { + productKind: 'stock', + stockSku: 'BAR001', + stockCategory: '餐桌', + stockItemType: '成品', + stockSalesStatus: '销售', + stockEnabledStatus: '1', + stockDataScope: 'normalOnSale', + }) + + expect(result.products).toHaveLength(1) + expect(mockedRepository.listActivePage).toHaveBeenCalledWith( + expect.objectContaining({ + filterWhere: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ + profile: { + is: { + sourceSpu: { + not: '', + }, + }, + }, + }), + ]), + }), + ]), + }), + }), + ) + expect(JSON.stringify(mockedRepository.listActivePage.mock.calls[0]?.[0])).toContain('sourceCategory') + expect(JSON.stringify(mockedRepository.listActivePage.mock.calls[0]?.[0])).toContain('sourceSalesStatus') + }) + + it('开发类型筛选只在缺少结构化 profile 时回退旧 payload', async () => { + mockedRepository.listActivePage.mockResolvedValue({ + records: [], + total: 0, + } as never) + + await intakeNonIntegratedProductService.listPage(baseUser, { + productDevelopmentKind: 'series', + }) + + const filterWhere = mockedRepository.listActivePage.mock.calls[0]?.[0]?.filterWhere + const serializedFilter = JSON.stringify(filterWhere) + + expect(serializedFilter).toContain('"productDevelopmentKind"') + expect(serializedFilter).toContain('"profile":null') + }) + + it('负责人筛选只在缺少结构化 profile 时回退旧 payload 人员字段', async () => { + mockedRepository.listActivePage.mockResolvedValue({ + records: [], + total: 0, + } as never) + + await intakeNonIntegratedProductService.listPage(baseUser, { + planningOwner: '旧企划', + spaceDesigner: '旧空间', + purchaseOwner: '旧采购', + }) + + const filterWhere = mockedRepository.listActivePage.mock.calls[0]?.[0]?.filterWhere + const serializedFilter = JSON.stringify(filterWhere) + + expect(serializedFilter).toContain('"productPlanning"') + expect(serializedFilter).toContain('"visualPlanning"') + expect(serializedFilter).toContain('"purchaseOwner"') + expect(serializedFilter).toContain('"profile":null') + }) + + it('存量商品分类下拉只读取结构化 profile 分类', async () => { + mockedRepository.listActive.mockResolvedValue([ + createRecord({ + profile: { + sourceCategory: '结构化分类', + }, + payload: createPayload({ + metadata: { + sourceCategory: '旧分类', + }, + }), + }), + createRecord({ + id: 'legacy-stock', + profile: null, + payload: createPayload({ + metadata: { + sourceCategory: '旧分类', + }, + }), + }), + ] as never) + + const options = await intakeNonIntegratedProductService.listStockCategoryOptions(baseUser) + + expect(options).toEqual(['结构化分类']) + }) + it('allows reading a shared record from another department when permissions are relaxed', async () => { mockedRepository.findActiveById.mockResolvedValue( createRecord({ @@ -248,6 +616,9 @@ describe('intakeNonIntegratedProductService', () => { await intakeNonIntegratedProductService.save(payload, baseUser) + const createArg = mockedRepository.create.mock.calls[0]?.[0] as { + profile?: { create?: Record } + } expect(mockedRepository.create).toHaveBeenCalledWith( expect.objectContaining({ id: 'non-integrated-create', @@ -258,6 +629,486 @@ describe('intakeNonIntegratedProductService', () => { updatedBy: 'creator-1', }), ) + expect(createArg.profile?.create).toEqual( + expect.objectContaining({ + productName: 'preview.png', + productImageUrl: 'data:image/png;base64,test-image', + source: 'PAYLOAD_SYNC', + }), + ) + }) + + it('在保存事务内分配正式 SPU 并同步生成 SKU 编码', async () => { + const payload = createPayload({ + id: 'draft-before-formal-spu', + spu: '', + productCategory: '餐桌', + productDevelopmentKind: 'product', + openingReference: { + productCategory: '餐桌', + }, + skuItems: [ + createSkuItem({ + skuSequence: 2, + skuCode: '', + barcode: '', + manufacturerCode: 'n05', + versionNo: '1', + }), + ], + }) + mockedSpuNumberReservationService.reserveInTransaction.mockResolvedValue({ + productCategory: '餐桌', + prefix: 'CZ', + index: 8, + spu: 'CZ0008', + }) + mockedRepository.findDeletedBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.createInTransaction.mockResolvedValue( + createRecord({ id: 'CZ0008-1770000000000' }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'CZ0008-1770000000000', + spu: 'CZ0008', + spuBase: 'CZ0008', + payload: createPayload({ id: 'CZ0008-1770000000000', spu: 'CZ0008' }), + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser, { + formalSpuAssignmentProductCategory: '餐桌', + }) + + expect(mockedPrisma.$transaction).toHaveBeenCalledTimes(1) + expect(mockedSpuNumberReservationService.reserveInTransaction).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + productCategory: '餐桌', + currentProductId: 'draft-before-formal-spu', + userId: 'creator-1', + }), + ) + const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as { + spu?: string + skuItems?: { createMany?: { data?: Array> } } + } + expect(createArg.spu).toBe('CZ0008') + expect(createArg.skuItems?.createMany?.data?.[0]).toEqual( + expect.objectContaining({ + skuCode: 'CZ000802', + barcode: 'CZ000802N05V01', + versionNo: 'V01', + }), + ) + }) + + it('分配正式 SPU 时会替换草稿临时 SKU 编码和条码', async () => { + const payload = createPayload({ + id: 'draft-with-temporary-sku', + spu: '', + productCategory: '餐桌', + productDevelopmentKind: 'product', + openingReference: { + productCategory: '餐桌', + }, + skuItems: [ + createSkuItem({ + skuSequence: 3, + skuCode: 'KP-2026043003', + barcode: 'KP-2026043003N05V01', + manufacturerCode: 'n05', + versionNo: '1', + }), + ], + }) + mockedSpuNumberReservationService.reserveInTransaction.mockResolvedValue({ + productCategory: '餐桌', + prefix: 'CZ', + index: 10, + spu: 'CZ0010', + }) + mockedRepository.findDeletedBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.createInTransaction.mockResolvedValue( + createRecord({ id: 'CZ0010-1770000000000' }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'CZ0010-1770000000000', + spu: 'CZ0010', + spuBase: 'CZ0010', + payload: createPayload({ id: 'CZ0010-1770000000000', spu: 'CZ0010' }), + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser, { + formalSpuAssignmentProductCategory: '餐桌', + }) + + const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as { + skuItems?: { createMany?: { data?: Array> } } + } + expect(createArg.skuItems?.createMany?.data?.[0]).toEqual( + expect.objectContaining({ + skuCode: 'CZ001003', + barcode: 'CZ001003N05V01', + versionNo: 'V01', + }), + ) + }) + + it('分配正式 SPU 前必须补齐 SKU 主数据', async () => { + const payload = createPayload({ + id: 'draft-incomplete-before-formal-spu', + spu: '', + productCategory: '餐桌', + productDevelopmentKind: '', + openingReference: { + productCategory: '餐桌', + }, + skuItems: [ + createSkuItem({ + skuSequence: 1, + specName: '', + model: '', + skuCode: '', + barcode: '', + }), + ], + }) + mockedSpuNumberReservationService.reserveInTransaction.mockResolvedValue({ + productCategory: '餐桌', + prefix: 'CZ', + index: 11, + spu: 'CZ0011', + }) + mockedRepository.findDeletedBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never) + + const promise = intakeNonIntegratedProductService.save(payload, baseUser, { + formalSpuAssignmentProductCategory: '餐桌', + }) + + await expect(promise).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_REQUIRED_FIELD, + }) + await expect(promise).rejects.toThrow('正式 SPU 分配存在未补齐的 SKU 主数据') + + expect(mockedRepository.createInTransaction).not.toHaveBeenCalled() + expect(mockedRepository.updateInTransaction).not.toHaveBeenCalled() + }) + + it('编辑旧草稿时在同一保存事务内分配正式 SPU 并更新原记录', async () => { + const payload = createPayload({ + id: 'draft-existing', + spu: '', + productCategory: '餐桌', + productDevelopmentKind: 'product', + openingReference: { + productCategory: '餐桌', + }, + skuItems: [ + createSkuItem({ + skuSequence: 1, + skuCode: '', + barcode: '', + manufacturerCode: 'N05', + versionNo: '01', + }), + ], + }) + mockTransactionExistingRecord = createRecord({ + id: 'draft-existing', + spu: 'DRAFT-001', + spuBase: 'DRAFT-001', + payload, + }) + mockedSpuNumberReservationService.reserveInTransaction.mockResolvedValue({ + productCategory: '餐桌', + prefix: 'CZ', + index: 9, + spu: 'CZ0009', + }) + mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.updateInTransaction.mockResolvedValue( + createRecord({ id: 'draft-existing', spu: 'CZ0009', spuBase: 'CZ0009' }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'draft-existing', + spu: 'CZ0009', + spuBase: 'CZ0009', + payload: createPayload({ id: 'draft-existing', spu: 'CZ0009' }), + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser, { + formalSpuAssignmentProductCategory: '餐桌', + }) + + expect(mockedRepository.createInTransaction).not.toHaveBeenCalled() + expect(mockedRepository.updateInTransaction).toHaveBeenCalledWith( + expect.any(Object), + 'draft-existing', + expect.objectContaining({ + spu: 'CZ0009', + spuBase: 'CZ0009', + }), + ) + const updatePayload = mockedRepository.updateInTransaction.mock.calls[0]?.[2] + ?.payload as Record + expect(updatePayload.spu).toBe('CZ0009') + expect((updatePayload.skuItems as Array>)[0]).toEqual( + expect.objectContaining({ + skuCode: 'CZ000901', + barcode: 'CZ000901N05V01', + }), + ) + }) + + it('syncs non-integrated SKU rows into structured master data on create', async () => { + const skuItem = createSkuItem({ + id: 'sku-ready-1', + skuSequence: 3, + batchNo: 2, + model: 'MODEL-READY', + skuCode: 'SKU-READY-001', + specName: 'Ready spec', + barcode: 'BAR-READY-001', + sourceTable: 'jst_sku', + sourceSkuId: 'jst-001', + }) + const payload = createPayload({ + id: 'non-integrated-structured-create', + metadata: { + sourceSpu: 'SPU001', + sourceSkuId: 'BAR-READY-001', + sourceCategory: '餐桌', + sourceItemType: '成品', + sourceSalesStatus: '销售', + sourceEnabled: '1', + sourceStockDisabled: '0', + }, + skuItems: [skuItem], + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findDeletedBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue(null as never) + mockedRepository.create.mockResolvedValue( + createRecord({ + id: 'non-integrated-structured-create', + payload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'non-integrated-structured-create', + payload, + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser) + + const createArg = mockedRepository.create.mock.calls[0]?.[0] as { + profile?: { create?: Record } + skuItems?: { createMany?: { data?: Array> } } + } + expect(createArg.profile?.create).toEqual( + expect.objectContaining({ + productName: 'preview.png', + productImageUrl: 'data:image/png;base64,test-image', + sourceSpu: 'SPU001', + sourceSkuId: 'BAR-READY-001', + sourceCategory: '餐桌', + sourceItemType: '成品', + sourceSalesStatus: '销售', + sourceEnabled: '1', + sourceStockDisabled: '0', + stockSkuCount: 1, + stockHasOnSaleSku: true, + }), + ) + expect(createArg.skuItems?.createMany?.data?.[0]).toEqual( + expect.objectContaining({ + id: 'non-integrated-structured-create:sku-ready-1', + skuItemId: 'sku-ready-1', + sortOrder: 1, + skuSequence: 3, + batchNo: 1, + model: 'MODEL-READY', + skuCode: 'SKU-READY-001', + specName: 'Ready spec', + barcode: 'BAR-READY-001', + sourceTable: 'jst_sku', + sourceSkuId: 'jst-001', + dataQualityStatus: 'READY', + missingRequiredFields: [], + }), + ) + }) + + it('refreshes structured SKU rows and marks incomplete rows on update', async () => { + const existingRecord = createRecord({ + id: 'non-integrated-structured-update', + payload: createPayload({ id: 'non-integrated-structured-update' }), + }) + const payload = createPayload({ + id: 'non-integrated-structured-update', + skuItems: [ + createSkuItem({ + id: 'sku-incomplete-1', + model: '', + skuCode: '', + specName: '', + barcode: '', + }), + ], + }) + + mockedRepository.findById.mockResolvedValue(existingRecord as never) + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.update.mockResolvedValue( + createRecord({ + id: 'non-integrated-structured-update', + payload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'non-integrated-structured-update', + payload, + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser) + + const updateArg = mockedRepository.update.mock.calls[0]?.[1] as { + profile?: { upsert?: { create?: Record; update?: Record } } + skuItems?: { + deleteMany?: Record + createMany?: { data?: Array> } + } + } + expect(updateArg.profile?.upsert?.update).toEqual( + expect.objectContaining({ + productName: 'preview.png', + productImageUrl: 'data:image/png;base64,test-image', + }), + ) + expect(updateArg.skuItems?.deleteMany).toEqual({}) + expect(updateArg.skuItems?.createMany?.data?.[0]).toEqual( + expect.objectContaining({ + id: 'non-integrated-structured-update:sku-incomplete-1', + skuItemId: 'sku-incomplete-1', + dataQualityStatus: 'INCOMPLETE', + missingRequiredFields: ['sku_code', 'spec_name', 'model'], + }), + ) + }) + + it('normalizes non-integrated SKU code and barcode before saving payload and structured rows', async () => { + const existingRecord = createRecord({ + id: 'non-integrated-normalized-sku', + payload: createPayload({ id: 'non-integrated-normalized-sku' }), + }) + const payload = createPayload({ + id: 'non-integrated-normalized-sku', + skuItems: [ + createSkuItem({ + id: 'sku-normalized-1', + skuCode: ' sku-lower-001 ', + barcode: ' bar-lower-001 ', + }), + ], + }) + + mockedRepository.findById.mockResolvedValue(existingRecord as never) + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.update.mockResolvedValue( + createRecord({ + id: 'non-integrated-normalized-sku', + payload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'non-integrated-normalized-sku', + payload, + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser) + + const updateArg = mockedRepository.update.mock.calls[0]?.[1] as { + payload?: { skuItems?: Array> } + skuItems?: { createMany?: { data?: Array> } } + } + expect(updateArg.payload?.skuItems?.[0]).toEqual( + expect.objectContaining({ + skuCode: 'SKU-LOWER-001', + barcode: 'BAR-LOWER-001', + }), + ) + expect(updateArg.skuItems?.createMany?.data?.[0]).toEqual( + expect.objectContaining({ + skuCode: 'SKU-LOWER-001', + barcode: 'BAR-LOWER-001', + }), + ) + expect(buildStructuredSkuItemInputs(payload)[0]).toEqual( + expect.objectContaining({ + skuCode: 'SKU-LOWER-001', + barcode: 'BAR-LOWER-001', + }), + ) + }) + + it('blocks formal downstream use when non-integrated SKU master data is incomplete', () => { + const product = createPayload({ + skuItems: [ + createSkuItem({ id: 'sku-ready', skuCode: 'SKU-READY', specName: 'Ready', model: 'M-READY' }), + createSkuItem({ id: 'sku-incomplete', skuCode: '', specName: '', model: '' }), + ], + }) + + expect(resolveNonIntegratedSkuQuality(product.skuItems[0])).toEqual({ + dataQualityStatus: 'READY', + missingRequiredFields: [], + }) + expect(resolveNonIntegratedSkuQuality(product.skuItems[1])).toEqual({ + dataQualityStatus: 'INCOMPLETE', + missingRequiredFields: ['sku_code', 'spec_name', 'model'], + }) + expect(() => + assertNonIntegratedSkuItemsReadyForFormalUse(product, { + usageLabel: '产品定厂合同', + }), + ).toThrow('产品定厂合同存在未补齐的 SKU 主数据') + }) + + it('blocks formal downstream use when non-integrated product profile master data is incomplete', () => { + const product = createPayload({ + productDevelopmentKind: '', + productCategory: '', + openingReference: { + productName: '待补齐开品', + productCategory: '', + }, + skuItems: [createSkuItem()], + }) + + expect(resolveNonIntegratedProductMissingRequiredFields(product)).toEqual([ + 'product_category', + 'product_development_kind', + ]) + expect(() => + assertNonIntegratedProductMasterDataReadyForFormalUse(product, { + usageLabel: '产品定厂合同', + }), + ).toThrow('产品定厂合同存在未补齐的产品主数据') }) it('persists a draft demand without formal spu by falling back to temporary code', async () => { @@ -1086,6 +1937,81 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).not.toHaveBeenCalled() }) + it('uses structured profile rather than legacy payload to classify stock write permissions', async () => { + const existingFactoryRows = [ + { + skuId: 'stock-sku-1', + confirmed: false, + model: 'AD0302', + skuCode: 'GBPM-AD030201', + setCount: '1', + barcode: 'GBPM-AD030201N1280V01', + specName: '存量款式', + size: '', + color: '', + material: '', + installMethod: '', + supplierName: '供应商A', + supplierCode: 'N1280', + warehouse: '', + minimumOrderQuantity: '', + leadTime: '', + assemblyMode: '', + productionType: '', + installationService: '', + supplierCategory: '', + purchaseOrderMethod: '', + executeStandard: '', + }, + ] + const existingPayload = createPayload({ + id: 'stock-product-profile-source', + processTemplate: '普通产品', + purchaser: '采购A', + purchaseOwner: '采购A', + openingReference: { + currentStage: '普通产品', + }, + factoryRows: existingFactoryRows, + }) + const incomingPayload = createPayload({ + ...existingPayload, + factoryRows: existingFactoryRows.map((row) => ({ + ...row, + confirmed: true, + supplierName: '供应商B', + })), + }) + const structuredProfile = { + processTemplate: '存量商品', + productStatus: '存量商品', + } + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + id: 'stock-product-profile-source', + payload: existingPayload, + profile: structuredProfile, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'purchase-1', + email: 'purchase@example.com', + name: '采购A', + businessRoles: [] as const, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部', + ], + }), + ).rejects.toThrow('没有权限编辑这条非集成产品记录') + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + it('does not rewrite stock product legacy values when there is no permitted node change', async () => { const existingPayload = createPayload({ id: 'stock-readonly-noop-save', @@ -1362,13 +2288,10 @@ describe('intakeNonIntegratedProductService', () => { }) as never, ) - const savedProduct = await intakeNonIntegratedProductService.save( - payload, - { - ...baseUser, - role: 'SUPER_ADMIN', - }, - ) + const savedProduct = await intakeNonIntegratedProductService.save(payload, { + ...baseUser, + role: 'ADMIN', + }) const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as | Record | undefined @@ -1537,7 +2460,7 @@ describe('intakeNonIntegratedProductService', () => { payload: uploadedPayload, }) as never, ) - mockedRepository.updatePayloadByUser.mockResolvedValue({} as never) + mockedRepository.updateAttachmentPayloadByUser.mockResolvedValue({} as never) const result = await intakeNonIntegratedProductService.attachUploadFile( 'non-integrated-1', @@ -1556,7 +2479,7 @@ describe('intakeNonIntegratedProductService', () => { baseUser, ) - const payloadArg = mockedRepository.updatePayloadByUser.mock.calls[0]?.[1] as Record< + const payloadArg = mockedRepository.updateAttachmentPayloadByUser.mock.calls[0]?.[1] as Record< string, unknown > @@ -1630,7 +2553,7 @@ describe('intakeNonIntegratedProductService', () => { }), }) as never, ) - mockedRepository.updatePayloadByUser.mockResolvedValue({} as never) + mockedRepository.updateAttachmentPayloadByUser.mockResolvedValue({} as never) const result = await intakeNonIntegratedProductService.savePresentationPreview( 'non-integrated-1', @@ -1648,7 +2571,7 @@ describe('intakeNonIntegratedProductService', () => { baseUser, ) - const payloadArg = mockedRepository.updatePayloadByUser.mock.calls[0]?.[1] as Record< + const payloadArg = mockedRepository.updateAttachmentPayloadByUser.mock.calls[0]?.[1] as Record< string, unknown > diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts index 9372d7e3..ee7c7797 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts @@ -31,6 +31,10 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { visualPlanning: '其他空间同事', visualDirector: '其他空间同事', }, + profile: { + visualPlanning: '希文', + visualDirector: '', + }, } const spaceUser: AuthUser = { id: 'xiweng-user-id', @@ -71,6 +75,10 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { visualPlanning: '其他空间同事', visualDirector: '其他空间同事', }, + profile: { + visualPlanning: '希文', + visualDirector: '', + }, } const spaceUser: AuthUser = { id: 'xiweng-user-id', @@ -108,6 +116,10 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { visualPlanning: 'space colleague', visualDirector: 'space colleague', }, + profile: { + visualPlanning: 'xiweng', + visualDirector: '', + }, } const spaceUser: AuthUser = { id: 'xiweng-user-id', @@ -135,4 +147,65 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '绌洪棿甯冨眬'), ).resolves.toBe(false) }) + + it('does not use legacy payload people fields for workspace permissions', async () => { + const record = { + id: 'stock-space-record', + createdBy: 'designer-id', + designer: '设计同事', + purchaser: '采购同事', + visibility: 'DEPARTMENT', + departmentId: null, + teamId: null, + payload: { + purchaseOwner: '采购A', + visualPlanning: '希文', + visualDirector: '希文', + }, + profile: { + purchaseOwner: '', + visualPlanning: '', + visualDirector: '', + }, + } + const spaceUser: AuthUser = { + id: 'xiweng-user-id', + email: 'xiweng@example.local', + name: '希文', + role: 'USER', + status: 'ACTIVE', + businessRoles: ['PLANNING'], + department: { + id: 'space-dept', + name: '空间视觉部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + }, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', + ], + } + + await expect( + service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '空间布局'), + ).resolves.toBe(true) + + const purchaseUser: AuthUser = { + id: 'purchase-user-id', + email: 'purchase@example.local', + name: '采购A', + role: 'USER', + status: 'ACTIVE', + businessRoles: ['PURCHASING'], + department: { + id: 'purchase-dept', + name: '采购一部', + fullPath: '江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部', + }, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部', + ], + } + + expect(service.canWriteWorkspaceRecord(record, purchaseUser, '产品定厂')).toBe(false) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts index 774b8339..5a4430cd 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts @@ -23,9 +23,6 @@ jest.mock('../../repositories', () => ({ findManyWithRelations: jest.fn(), findById: jest.fn(), }, - skuRepository: { - findByProductIdAndSkuCode: jest.fn(), - }, assetVariantRepository: { findByIdsWithRelations: jest.fn(), findByIdsWithRelationsIncludingDeleted: jest.fn(), @@ -40,7 +37,6 @@ import { draftGroupRepository, preSkuRepository, productRepository, - skuRepository, assetVariantRepository, warehouseLocationRepository, } from '../../repositories' @@ -51,12 +47,13 @@ describe('intakeService', () => { const mockedDraftGroupRepository = jest.mocked(draftGroupRepository) const mockedPreSkuRepository = jest.mocked(preSkuRepository) const mockedProductRepository = jest.mocked(productRepository) - const mockedSkuRepository = jest.mocked(skuRepository) const mockedAssetVariantRepository = jest.mocked(assetVariantRepository) const mockedWarehouseLocationRepository = jest.mocked(warehouseLocationRepository) let transactionClient: Prisma.TransactionClient let mockCreateProduct: jest.Mock + let mockFindFirstProduct: jest.Mock let mockCreateSku: jest.Mock + let mockFindFirstSku: jest.Mock let mockCreatePanel: jest.Mock let mockCreateHole: jest.Mock let mockCreateProductHardware: jest.Mock @@ -108,6 +105,78 @@ describe('intakeService', () => { } } + function buildAssetVariant(id = 'variant-desktop-a-white', overrides: Record = {}) { + const isHardware = id.includes('connector') || id.includes('hardware') + return { + id, + assetFamilyId: isHardware ? 'family-connector-bracket' : 'family-desktop-a', + code: isHardware ? 'CT-BRACKET-001' : 'DBZ-SPJ00001', + name: isHardware ? '桌腿连接片 标准款' : '桌面 1200 柚木色', + assetType: isHardware ? 'HARDWARE' : 'BOARD', + status: 'AVAILABLE', + identityKey: id, + sizeLabel: null, + substrateId: isHardware ? null : 'substrate-board', + finishId: isHardware ? null : 'finish-teak', + dimensions: isHardware ? { length: 80, width: 40, thickness: 5 } : { length: 1200, width: 600, thickness: 25 }, + modelUrl: isHardware ? '/models/bracket.glb' : '/models/top-a-1200.glb', + thumbnailUrl: isHardware ? '/thumbs/bracket.webp' : '/thumbs/top.webp', + barcode: isHardware ? 'CT-BRACKET-001N05V01' : 'DBZ-SPJ00001N05V01', + supplierName: null, + supplierCode: 'N05', + versionNo: 'V01', + mountPoints: [{ name: 'origin', position: [0, 0, 0], diameter: 5, depth: 10 }], + assemblyBasis: {}, + compatibilityRules: null, + namingFields: null, + businessData: { materialLabel: isHardware ? '黑色金属' : '颗粒板 + 柚木色' }, + uvConfig: null, + weightKg: isHardware ? 0.2 : 14.5, + costPrice: isHardware ? 3 : 210, + lossFactor: null, + estimatedCostPrice: null, + inheritedFromVariantId: null, + deletedAt: null, + assetFamily: { + id: isHardware ? 'family-connector-bracket' : 'family-desktop-a', + code: isHardware ? 'DBZ-SPJK014' : 'DBZ-SPJK001', + name: isHardware ? '连接片' : '桌面板', + type: isHardware ? 'HARDWARE' : 'BOARD', + assetSeriesId: 'series-template-1', + deletedAt: null, + assetSeries: { id: 'series-template-1', code: 'DBZ', name: '单板桌' }, + }, + substrate: isHardware ? null : { id: 'substrate-board', code: 'SB', name: '颗粒板' }, + finish: isHardware + ? null + : { + id: 'finish-teak', + name: '柚木色', + code: 'TEAK', + categoryId: 'finish', + category: { id: 'finish', name: '饰面', icon: null }, + thumbnailUrl: null, + textureUrl: null, + normalMapUrl: null, + color: '#996633', + metalness: 0, + roughness: 0.5, + opacity: 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: true, + isActive: true, + }, + creator: null, + submitter: null, + reviewer: null, + rejecter: null, + lastModifier: null, + ...overrides, + } + } + function buildFormalizablePreSku(overrides: Record = {}) { return { id: 'pre-sku-1', @@ -118,10 +187,42 @@ describe('intakeService', () => { status: 'DRAFT', componentCodes: ['TOP-A-WH-1200'], bomPreview: [], + basicInfo: { + model: 'DBZ-SPJK001', + formalSkuCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + size: '长1200mm / 宽600mm / 厚25mm', + color: '柚木色', + material: '颗粒板 + 柚木色', + specName: '桌面 1200 柚木色', + versionNo: 'V01', + setCount: null, + attributeCode: null, + drawingId: null, + installMethod: null, + }, displaySummary: {}, installStepPreview: [], variantSelection: { - slots: [], + slots: [ + { + slotKey: 'desktop', + role: 'desktop', + assetFamilyId: 'family-desktop-a', + variantId: 'variant-desktop-a-white', + code: 'OLD-CODE', + name: '旧快照桌面', + assetType: 'BOARD', + substrateId: 'substrate-board', + finishId: 'finish-teak', + dimensions: { length: 1000, width: 500, thickness: 18 }, + weightKg: 12, + costPrice: 180, + modelUrl: '/models/old.glb', + mountPoints: [], + assemblyBasis: null, + }, + ], hardwareSelections: [], }, packagingPlan: buildPackagingPlan(), @@ -133,7 +234,9 @@ describe('intakeService', () => { jest.clearAllMocks() mockCreateProduct = jest.fn() + mockFindFirstProduct = jest.fn().mockResolvedValue(null) mockCreateSku = jest.fn().mockResolvedValue({ id: 'sku-1' }) + mockFindFirstSku = jest.fn().mockResolvedValue(null) mockCreatePanel = jest.fn().mockResolvedValue({ id: 'panel-1' }) mockCreateHole = jest.fn().mockResolvedValue({ id: 'hole-1' }) mockCreateProductHardware = jest.fn().mockResolvedValue({ id: 'product-hardware-1' }) @@ -145,10 +248,14 @@ describe('intakeService', () => { transactionClient = { product: { + findFirst: mockFindFirstProduct, create: mockCreateProduct, }, productSKU: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: mockFindFirstSku, create: mockCreateSku, + update: jest.fn(), }, panel: { create: mockCreatePanel, @@ -193,8 +300,9 @@ describe('intakeService', () => { name: '测试 SPU', } as never) - mockedSkuRepository.findByProductIdAndSkuCode.mockResolvedValue(null as never) - mockedAssetVariantRepository.findByIdsWithRelationsIncludingDeleted.mockResolvedValue([] as never) + mockedAssetVariantRepository.findByIdsWithRelationsIncludingDeleted.mockImplementation(async (ids) => + ids.map((id) => buildAssetVariant(id)) as never, + ) mockedWarehouseLocationRepository.findById.mockResolvedValue(null as never) mockedPrisma.$transaction.mockImplementation(async (callback) => { @@ -327,6 +435,36 @@ describe('intakeService', () => { }) }) + it('挂已有 SPU 但目标产品已删除时不允许正式入库', async () => { + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(buildFormalizablePreSku()) + mockedProductRepository.findById.mockResolvedValue({ + id: 'product-1', + modelNumber: 'SPU-001', + name: '已删除 SPU', + deletedAt: new Date('2026-05-31T12:00:00.000Z'), + } as never) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).rejects.toMatchObject({ + code: ErrorCode.PRODUCT_NOT_FOUND, + }) + }) + it('传入不存在的库位时应阻止正式入库', async () => { mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ id: 'draft-group-1', @@ -352,7 +490,7 @@ describe('intakeService', () => { }) }) - it('CREATE_PRODUCT 模式会创建新的产品资料并回写默认信息', async () => { + it('CREATE_PRODUCT 模式必须使用显式正式产品身份,不再生成临时 SPU', async () => { mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ id: 'draft-group-1', name: '云序桌系第一组', @@ -371,14 +509,21 @@ describe('intakeService', () => { draftGroupId: 'draft-group-1', targetMode: 'CREATE_PRODUCT', productCategoryId: 'category-1', + modelNumber: 'SPU-NEW-001', + productName: '云序桌正式款', skuItems: [{ preSkuId: 'pre-sku-1', initialStock: 3 }], }, authUser, ) + expect(mockFindFirstProduct).toHaveBeenCalledWith({ + where: { modelNumber: 'SPU-NEW-001' }, + select: { id: true }, + }) expect(mockCreateProduct).toHaveBeenCalledWith({ data: expect.objectContaining({ - name: '云序桌系第一组', + modelNumber: 'SPU-NEW-001', + name: '云序桌正式款', categoryId: 'category-1', createdBy: authUser.id, }), @@ -387,7 +532,217 @@ describe('intakeService', () => { expect(result.createdSkuIds).toEqual(['sku-1']) }) - it('正式入库会兼容 tuple 格式的 mountPoints 坐标', async () => { + it('CREATE_PRODUCT 模式遇到正式产品型号冲突时阻断', async () => { + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(buildFormalizablePreSku()) + mockFindFirstProduct.mockResolvedValue({ id: 'product-existing' }) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'CREATE_PRODUCT', + productCategoryId: 'category-1', + modelNumber: 'SPU-NEW-001', + productName: '云序桌正式款', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: expect.stringContaining('正式 SPU 型号「SPU-NEW-001」已存在'), + }) + + expect(mockCreateProduct).not.toHaveBeenCalled() + expect(mockCreateSku).not.toHaveBeenCalled() + }) + + it('正式入库遇到 SKU 编码冲突时阻断,不自动改写结构化规格码', async () => { + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(buildFormalizablePreSku()) + mockFindFirstSku + .mockResolvedValueOnce({ id: 'sku-existing-in-other-product' }) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: expect.stringContaining('正式 SKU 编码「DBZ-SPJ00001」已存在'), + }) + + expect(mockFindFirstSku).toHaveBeenCalledWith({ + where: { + skuCode: 'DBZ-SPJ00001', + deletedAt: null, + }, + select: { id: true }, + }) + expect(mockCreateSku).not.toHaveBeenCalled() + }) + + it('正式入库必须使用结构化 PreSkuBasicInfo,不允许 displaySummary 旧字段参与主写', async () => { + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue( + buildFormalizablePreSku({ + basicInfo: null, + displaySummary: { + formalSkuCode: 'OLD-JSON-SKU', + specName: '旧 JSON 规格名', + }, + }), + ) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: expect.stringContaining('缺少结构化基础资料'), + }) + expect(mockCreateSku).not.toHaveBeenCalled() + }) + + it('正式入库重复校验优先使用源 PreSku,而不是正式 SKU attributes 旧副本', async () => { + const currentPreSku = buildFormalizablePreSku() + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(currentPreSku) + mockFindManyExistingSkus.mockResolvedValue([ + { + id: 'sku-existing', + skuCode: 'SKU-OLD', + productId: 'product-existing', + product: { + id: 'product-existing', + name: '旧产品', + modelNumber: 'SPU-OLD', + }, + attributes: { + variantSelectionSummary: currentPreSku.variantSelection, + bomPreview: currentPreSku.bomPreview, + }, + sourcePreSku: { + variantSelection: { + slots: [ + { + slotKey: 'desktop', + role: 'desktop', + assetFamilyId: 'family-desktop-a', + variantId: 'variant-desktop-a-white', + code: 'DBZ-SPJ00009', + name: '当前源准 SKU 已调整后的桌面', + assetType: 'BOARD', + substrateId: 'substrate-board', + finishId: 'finish-teak', + dimensions: { length: 1400, width: 700, thickness: 25 }, + weightKg: 16, + costPrice: 230, + modelUrl: '/models/top-a-1400.glb', + mountPoints: [], + assemblyBasis: null, + }, + ], + hardwareSelections: [], + }, + bomPreview: [], + displaySummary: {}, + }, + }, + ]) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).resolves.toMatchObject({ + createdSkuIds: ['sku-1'], + }) + }) + + it('正式入库重复校验不会使用正式 SKU attributes 旧组合快照作为阻断依据', async () => { + const currentPreSku = buildFormalizablePreSku() + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(currentPreSku) + mockFindManyExistingSkus.mockResolvedValue([ + { + id: 'sku-existing', + skuCode: 'SKU-OLD', + productId: 'product-existing', + product: { + id: 'product-existing', + name: '旧产品', + modelNumber: 'SPU-OLD', + }, + attributes: { + variantSelectionSummary: currentPreSku.variantSelection, + bomPreview: currentPreSku.bomPreview, + }, + sourcePreSku: null, + }, + ]) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).resolves.toMatchObject({ + createdSkuIds: ['sku-1'], + }) + }) + + it('正式入库从资产规格款结构化数据生成板件、孔位和五金', async () => { mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ id: 'draft-group-1', name: '云序桌系第一组', @@ -442,6 +797,29 @@ describe('intakeService', () => { }, }), ) + mockedAssetVariantRepository.findByIdsWithRelationsIncludingDeleted.mockImplementation(async (ids) => + ids.map((id) => + id === 'variant-desktop-a-white' + ? buildAssetVariant(id, { + code: 'DBZ-SPJ00001', + name: '当前资产桌面', + dimensions: { length: 1200, width: 600, thickness: 25 }, + mountPoints: [ + { + name: 'desktop-hole-1', + position: [120, 80, 5], + diameter: 8, + depth: 15, + type: 'blind', + }, + ], + assemblyBasis: { facing: 'up' }, + }) + : buildAssetVariant(id, { + mountPoints: [{ name: 'origin', position: [300, 200, 12] }], + }), + ) as never, + ) await intakeService.formalizeDraftGroup( { @@ -465,6 +843,17 @@ describe('intakeService', () => { }), }), ) + expect(mockCreatePanel).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + code: 'DBZ-SPJ00001', + name: '当前资产桌面', + length: 1200, + width: 600, + thickness: 25, + }), + }), + ) expect(mockCreateProductHardware).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -476,6 +865,36 @@ describe('intakeService', () => { ) }) + it('准 SKU 引用的资产规格款已删除时阻止正式入库', async () => { + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(buildFormalizablePreSku()) + mockedAssetVariantRepository.findByIdsWithRelationsIncludingDeleted.mockResolvedValue([ + buildAssetVariant('variant-desktop-a-white', { + deletedAt: new Date('2026-05-30T08:00:00.000Z'), + }), + ] as never) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + expect(mockCreateSku).not.toHaveBeenCalled() + }) + it('正式入库时会按 boxes 生成包装成品,并写入规格款与包装摘要快照', async () => { mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ id: 'draft-group-1', @@ -491,6 +910,20 @@ describe('intakeService', () => { defaultName: '云序桌-标准版', customName: '云序桌-黑腿版', componentCodes: ['TOP-A-WH-1200', 'LEG-SLIM-BK-720', 'CT-BRACKET-001'], + basicInfo: { + model: 'DBZ-SPJK001', + formalSkuCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + size: '长1200mm / 宽600mm / 厚25mm', + color: '白橡', + material: '颗粒板 + 白橡', + specName: '云序桌-黑腿版', + versionNo: 'V01', + setCount: null, + attributeCode: null, + drawingId: null, + installMethod: null, + }, bomPreview: [ { variantId: 'variant-desktop-a-white', @@ -501,6 +934,9 @@ describe('intakeService', () => { ], displaySummary: { defaultName: '云序桌-标准版', + formalSkuCode: 'OLD-JSON-SKU', + barcode: 'OLD-JSON-BARCODE', + color: '旧快照颜色', }, installStepPreview: [{ title: '固定桌面板' }], variantSelection: { @@ -597,9 +1033,33 @@ describe('intakeService', () => { name: '测试 SPU', }) mockedAssetVariantRepository.findByIdsWithRelationsIncludingDeleted.mockResolvedValue([ - { - id: 'variant-desktop-a-white', - assetFamilyId: 'family-desktop-a', + buildAssetVariant('variant-desktop-a-white', { + code: 'DBZ-SPJ00001', + name: '悬浮桌面 A 1200 白橡', + barcode: 'DBZ-SPJ00001N05V01', + dimensions: { length: 1200, width: 600, thickness: 25 }, + thumbnailUrl: '/asset-thumbnails/desktop.webp', + finish: { + id: 'finish-oak', + name: '白橡', + code: 'OAK', + categoryId: 'finish', + category: { id: 'finish', name: '饰面', icon: null }, + thumbnailUrl: null, + textureUrl: null, + normalMapUrl: null, + color: '#fff', + metalness: 0, + roughness: 0.5, + opacity: 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: true, + isActive: true, + }, + substrate: { id: 'substrate-pb', code: 'PB18', name: '颗粒板' }, + businessData: { materialLabel: '颗粒板 + 白橡' }, assetFamily: { id: 'family-desktop-a', code: 'DBZ-SPJK001', @@ -609,19 +1069,14 @@ describe('intakeService', () => { assetSeries: { id: 'series-dbz', code: 'DBZ', name: '单板桌' }, deletedAt: null, }, - code: 'DBZ-SPJ00001', - name: '悬浮桌面 A 1200 白橡', - barcode: 'DBZ-SPJ00001N05V01', - dimensions: { length: 1200, width: 600, thickness: 25 }, - thumbnailUrl: '/asset-thumbnails/desktop.webp', - finish: { id: 'finish-oak', name: '白橡' }, - substrate: { id: 'substrate-pb', code: 'PB18', name: '颗粒板' }, - businessData: { materialLabel: '颗粒板 + 白橡' }, - deletedAt: null, - }, - { - id: 'variant-connector-bracket', - assetFamilyId: 'family-connector-bracket', + }), + buildAssetVariant('variant-connector-bracket', { + code: 'DBZ-SPJ00002', + name: '桌腿连接片 标准款', + barcode: null, + dimensions: { length: 80, width: 40, thickness: 3 }, + thumbnailUrl: null, + businessData: null, assetFamily: { id: 'family-connector-bracket', code: 'DBZ-SPJK002', @@ -631,16 +1086,7 @@ describe('intakeService', () => { assetSeries: { id: 'series-dbz', code: 'DBZ', name: '单板桌' }, deletedAt: null, }, - code: 'DBZ-SPJ00002', - name: '桌腿连接片 标准款', - barcode: null, - dimensions: { length: 80, width: 40, thickness: 3 }, - thumbnailUrl: null, - finish: null, - substrate: null, - businessData: null, - deletedAt: null, - }, + }), ] as never) await intakeService.formalizeDraftGroup( @@ -665,7 +1111,6 @@ describe('intakeService', () => { data: expect.objectContaining({ attributes: expect.objectContaining({ preSkuId: 'pre-sku-1', - skuName: '云序桌-黑腿版', variantSelectionSummary: expect.objectContaining({ slots: expect.any(Array), hardwareSelections: expect.any(Array), @@ -686,17 +1131,32 @@ describe('intakeService', () => { sourceStatus: 'ACTIVE', }), }), + name: '云序桌-黑腿版', images: ['/asset-thumbnails/desktop.webp'], sourceAssetVariantId: 'variant-desktop-a-white', sourceAssetVariantIds: ['variant-desktop-a-white', 'variant-connector-bracket'], + assetProfile: { + create: expect.objectContaining({ + primarySourceAssetVariantId: 'variant-desktop-a-white', + sourceAssetVariantIds: ['variant-desktop-a-white', 'variant-connector-bracket'], + model: 'DBZ-SPJK001', + specCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + color: '白橡', + material: '颗粒板 + 白橡', + imageUrl: '/asset-thumbnails/desktop.webp', + sourceStatus: 'ACTIVE', + }), + }, }), }), ) + expect(mockCreateSku.mock.calls[0][0].data.attributes).not.toHaveProperty('displaySummary') expect(mockCreatePackagedItem).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - code: 'SPU-001-01-PKG-1', + code: 'DBZ-SPJ00001-PKG-1', preSkuPackagingPlanId: 'plan-1', payload: expect.objectContaining({ boxNumber: 1, @@ -782,6 +1242,60 @@ describe('intakeService', () => { }) }) + it('包装箱编号必须是唯一正整数,避免生成冲突的包装成品编码', async () => { + mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ + id: 'draft-group-1', + name: '云序桌系第一组', + categoryId: 'category-1', + status: 'READY_FOR_INTAKE', + } as never) + + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue( + buildFormalizablePreSku({ + packagingPlan: { + ...buildPackagingPlan(), + boxes: [ + { + boxNumber: 1, + name: '主箱', + outerSizeMm: { length: 1200, width: 650, height: 90 }, + items: [], + materials: [], + estimatedWeightKg: 16.1, + estimatedCost: 12, + }, + { + boxNumber: 1, + name: '副箱', + outerSizeMm: { length: 400, width: 280, height: 180 }, + items: [], + materials: [], + estimatedWeightKg: 4.2, + estimatedCost: 9, + }, + ], + }, + }), + ) + + await expect( + intakeService.formalizeDraftGroup( + { + draftGroupId: 'draft-group-1', + targetMode: 'ATTACH_PRODUCT', + productId: 'product-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }, + authUser, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: expect.stringContaining('包装箱编号 1 重复'), + }) + + expect(mockCreatePackagedItem).not.toHaveBeenCalled() + }) + it('已经 FORMALIZED 的准 SKU 不允许再次正式入库', async () => { mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({ id: 'draft-group-1', @@ -869,23 +1383,7 @@ describe('intakeService', () => { status: 'READY_FOR_INTAKE', } as never) - mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue({ - id: 'pre-sku-1', - draftGroupId: 'draft-group-1', - sequence: 1, - defaultName: '云序桌-标准版', - customName: null, - status: 'DRAFT', - componentCodes: ['TOP-A-WH-1200'], - bomPreview: [], - displaySummary: {}, - installStepPreview: [], - variantSelection: { - slots: [], - hardwareSelections: [], - }, - packagingPlan: buildPackagingPlan(), - } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(buildFormalizablePreSku()) mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([ { @@ -942,23 +1440,7 @@ describe('intakeService', () => { status: 'READY_FOR_INTAKE', } as never) - mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue({ - id: 'pre-sku-1', - draftGroupId: 'draft-group-1', - sequence: 1, - defaultName: '云序桌-标准版', - customName: null, - status: 'DRAFT', - componentCodes: ['TOP-A-WH-1200'], - bomPreview: [], - displaySummary: {}, - installStepPreview: [], - variantSelection: { - slots: [], - hardwareSelections: [], - }, - packagingPlan: buildPackagingPlan(), - } as never) + mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(buildFormalizablePreSku()) mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([ { @@ -1027,4 +1509,29 @@ describe('intakeService', () => { ]), ) }) + + it('schema 要求新建 SPU 时必须传正式型号和产品名称', () => { + const result = formalizeDraftGroupSchema.safeParse({ + draftGroupId: 'draft-group-1', + targetMode: 'CREATE_PRODUCT', + productCategoryId: 'category-1', + skuItems: [{ preSkuId: 'pre-sku-1' }], + }) + + expect(result.success).toBe(false) + if (result.success) { + throw new Error('expected schema parse to fail') + } + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ['modelNumber'], + }), + expect.objectContaining({ + path: ['productName'], + }), + ]), + ) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/material.service.test.ts b/furniture-3d-system/src/server/services/__tests__/material.service.test.ts index fc987f82..7c09c96d 100644 --- a/furniture-3d-system/src/server/services/__tests__/material.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/material.service.test.ts @@ -6,6 +6,7 @@ jest.mock('@/server/repositories/material.repository', () => ({ create: jest.fn(), findByIdWithCategory: jest.fn(), findById: jest.fn(), + update: jest.fn(), getUsageCount: jest.fn(), deactivate: jest.fn(), delete: jest.fn(), @@ -13,6 +14,10 @@ jest.mock('@/server/repositories/material.repository', () => ({ materialCategoryRepository: { findById: jest.fn(), getMaterialCount: jest.fn(), + nameExists: jest.fn(), + getNextSortOrder: jest.fn(), + create: jest.fn(), + update: jest.fn(), delete: jest.fn(), }, })) @@ -45,6 +50,7 @@ const mockMaterialRepository = materialRepository as unknown as { create: jest.Mock findByIdWithCategory: jest.Mock findById: jest.Mock + update: jest.Mock getUsageCount: jest.Mock deactivate: jest.Mock delete: jest.Mock @@ -53,6 +59,10 @@ const mockMaterialRepository = materialRepository as unknown as { const mockMaterialCategoryRepository = materialCategoryRepository as unknown as { findById: jest.Mock getMaterialCount: jest.Mock + nameExists: jest.Mock + getNextSortOrder: jest.Mock + create: jest.Mock + update: jest.Mock delete: jest.Mock } @@ -65,6 +75,8 @@ describe('materialService', () => { icon: null, }) mockMaterialCategoryRepository.getMaterialCount.mockResolvedValue(0) + mockMaterialCategoryRepository.nameExists.mockResolvedValue(false) + mockMaterialCategoryRepository.getNextSortOrder.mockResolvedValue(3) mockMaterialRepository.nameExistsInCategory.mockResolvedValue(false) mockMaterialRepository.getUsageCount.mockResolvedValue(0) }) @@ -132,6 +144,67 @@ describe('materialService', () => { expect(result.previewUrl).toBe('/api/materials/thumbnail?name=test') }) + it('创建花色时会按统一编码池归一化人工编码', async () => { + mockMaterialRepository.findByCode.mockResolvedValue(null) + mockMaterialRepository.create.mockResolvedValue({ + id: 'finish-custom', + code: 'HS-0020', + name: '胡桃木', + categoryId: 'category-wood', + category: { id: 'category-wood', name: '木纹', icon: null }, + thumbnailUrl: null, + textureUrl: null, + normalMapUrl: null, + color: '#ffffff', + metalness: 0, + roughness: 0.5, + opacity: 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: false, + isActive: true, + }) + mockMaterialRepository.findByIdWithCategory.mockResolvedValue({ + id: 'finish-custom', + code: 'HS-0020', + name: '胡桃木', + categoryId: 'category-wood', + category: { id: 'category-wood', name: '木纹', icon: null }, + thumbnailUrl: null, + textureUrl: null, + normalMapUrl: null, + color: '#ffffff', + metalness: 0, + roughness: 0.5, + opacity: 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: false, + isActive: true, + }) + + await materialService.create({ + name: ' 胡桃木 ', + code: ' hs-0020 ', + categoryId: 'category-wood', + color: '#ffffff', + metalness: 0, + roughness: 0.5, + opacity: 1, + }) + + expect(mockMaterialRepository.findByCode).toHaveBeenCalledWith('HS-0020') + expect(mockMaterialRepository.nameExistsInCategory).toHaveBeenCalledWith('胡桃木', 'category-wood') + expect(mockMaterialRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'HS-0020', + name: '胡桃木', + }), + ) + }) + it('历史默认占位缩略图会在 DTO 层归一化为 null', async () => { mockMaterialRepository.findByIdWithCategory.mockResolvedValue({ id: 'finish-stone', @@ -177,4 +250,76 @@ describe('materialService', () => { expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', false) expect(mockMaterialCategoryRepository.delete).toHaveBeenCalledWith('category-wood') }) + + it('创建分类时会归一化名称图标并拒绝负排序', async () => { + mockMaterialCategoryRepository.create.mockResolvedValue({ + id: 'category-metal', + name: '金属', + icon: null, + sortOrder: 3, + isActive: true, + }) + + await materialService.createCategory({ + name: ' 金属 ', + icon: ' ', + }) + + expect(mockMaterialCategoryRepository.nameExists).toHaveBeenCalledWith('金属') + expect(mockMaterialCategoryRepository.create).toHaveBeenCalledWith({ + name: '金属', + icon: null, + sortOrder: 3, + isActive: true, + }) + + await expect( + materialService.createCategory({ + name: '玻璃', + sortOrder: -1, + }), + ).rejects.toThrow('分类排序必须是非负整数') + }) + + it('更新材质名称时会先去空格再查重和保存', async () => { + mockMaterialRepository.findById.mockResolvedValue({ + id: 'finish-oak', + name: '旧橡木', + categoryId: 'category-wood', + thumbnailUrl: null, + }) + mockMaterialRepository.findByIdWithCategory.mockResolvedValue({ + id: 'finish-oak', + code: 'HS-0010', + name: '北美橡木', + categoryId: 'category-wood', + category: { id: 'category-wood', name: '木纹', icon: null }, + thumbnailUrl: null, + textureUrl: null, + normalMapUrl: null, + color: '#ffffff', + metalness: 0, + roughness: 0.5, + opacity: 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: false, + isActive: true, + }) + + await materialService.update('finish-oak', { + name: ' 北美橡木 ', + }) + + expect(mockMaterialRepository.nameExistsInCategory).toHaveBeenCalledWith( + '北美橡木', + 'category-wood', + 'finish-oak', + ) + expect(mockMaterialRepository.update).toHaveBeenCalledWith( + 'finish-oak', + expect.objectContaining({ name: '北美橡木' }), + ) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts b/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts index 717aef7e..71aad868 100644 --- a/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts @@ -303,7 +303,7 @@ describe('planningService', () => { expect(mockedCategoryRepository.findById).toHaveBeenCalledWith('category-1') expect(mockedPlanningTemplateRepository.create).toHaveBeenCalledWith( expect.objectContaining({ - code: 'desk-pro', + code: 'DESK-PRO', name: '高配电脑桌', description: '面向旗舰款', namePattern: '{workspace.name}-{desktop.code}', @@ -313,6 +313,113 @@ describe('planningService', () => { expect(result.code).toBe('desk-basic') }) + it('创建模板时拒绝非对象规则,避免把过程 JSON 写成第二套真相', async () => { + mockedCategoryRepository.findById.mockResolvedValue({ + id: 'category-1', + name: '电脑桌', + } as never) + + await expect( + planningService.createTemplate( + { + code: 'desk-pro', + name: '高配电脑桌', + categoryId: 'category-1', + slots: [ + { + key: 'desktop', + name: '桌面板', + assetType: 'component', + placement: { + mode: 'absolute', + position: [0, 720, 0], + }, + }, + ], + rules: [] as never, + }, + user, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + expect(mockedPlanningTemplateRepository.create).not.toHaveBeenCalled() + }) + + it('创建工作台时规范化可见范围和槽位选择,并拒绝模板外槽位', async () => { + mockedCategoryRepository.findById.mockResolvedValue({ + id: 'category-1', + name: '电脑桌', + } as never) + mockedPlanningWorkspaceRepository.create.mockResolvedValue({ + id: 'workspace-created', + } as never) + + await planningService.createWorkspace( + { + name: ' 云序桌系 ', + templateId: 'template-1', + visibility: ' department ', + slotSelections: { + desktop: [' component-1 ', 'component-1'], + }, + metadata: { season: '2026Q2' }, + }, + user, + ) + + expect(mockedPlanningWorkspaceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: '云序桌系', + visibility: 'DEPARTMENT', + slotSelections: { + desktop: ['component-1'], + }, + }), + ) + + await expect( + planningService.createWorkspace( + { + name: '云序桌系', + templateId: 'template-1', + slotSelections: { + unknown: ['component-1'], + }, + }, + user, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + }) + + it('更新工作台时拒绝空名称和游离可见范围', async () => { + await expect( + planningService.updateWorkspace( + 'workspace-1', + { + name: ' ', + }, + user, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_REQUIRED_FIELD, + }) + + await expect( + planningService.updateWorkspace( + 'workspace-1', + { + visibility: 'ALL_HANDS', + }, + user, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + }) + it('保存草稿时只持久化选中资产的最小快照字段', async () => { mockedPlanningDraftRepository.create.mockResolvedValue({ id: 'draft-created', diff --git a/furniture-3d-system/src/server/services/__tests__/product-write.service.permission.test.ts b/furniture-3d-system/src/server/services/__tests__/product-write.service.permission.test.ts index 2a0f494b..54e0fffb 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-write.service.permission.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-write.service.permission.test.ts @@ -37,6 +37,7 @@ describe('ProductWriteService collaborator permissions', () => { name: '部门管理员', role: 'SUPERVISOR', status: 'ACTIVE', + businessRoles: ['DESIGN'], managedDepartmentId: 'dept-1', } as any, requestId: 'req-test', @@ -89,4 +90,46 @@ describe('ProductWriteService collaborator permissions', () => { code: ErrorCode.AUTH_PERMISSION_DENIED, }) }) + + it('协作者角色必须是固定枚举,不能把游离值写成权限关系', async () => { + const repository: any = { + findById: (jest.fn() as any).mockResolvedValue({ + id: 'product-3', + createdBy: 'creator-1', + departmentId: 'dept-1', + deletedAt: null, + }), + addCollaborator: jest.fn() as any, + } + + const service = new ProductWriteService( + repository, + { del: jest.fn() as any } as any, + jest.fn() as any, + jest.fn() as any, + ) + + await expect( + service.addCollaborator( + 'product-3', + 'user-2', + 'owner', + { + user: { + id: 'creator-1', + email: 'creator@test.com', + name: '创建者', + role: 'USER', + status: 'ACTIVE', + businessRoles: ['DESIGN'], + } as any, + requestId: 'req-test', + }, + ), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + + expect(repository.addCollaborator).not.toHaveBeenCalled() + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/share.service.test.ts b/furniture-3d-system/src/server/services/__tests__/share.service.test.ts index 58440b37..8ce38bff 100644 --- a/furniture-3d-system/src/server/services/__tests__/share.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/share.service.test.ts @@ -48,8 +48,10 @@ describe('ShareService.getShareDetail', () => { { id: 'sku-1', skuCode: 'SKU-001', + name: '测试 SKU', price: 99, attributes: { color: 'oak', _skuCadData: { nodes: ['internal-node'] }, internalCount: 2 }, + assetProfile: null, isActive: true, modelUrl: 'https://example.com/sku-1.glb', images: [], @@ -299,10 +301,13 @@ describe('ShareService.getShareDetail', () => { { id: 'sku-1', skuCode: 'SKU-001', + name: '测试 SKU', price: 99, - attributes: { color: 'oak' }, + assetProfile: null, + legacyAttributes: { color: 'oak' }, }, ]) + expect(JSON.stringify(result)).not.toContain('"attributes"') expect(JSON.stringify(result)).not.toContain('_skuCadData') expect(JSON.stringify(result)).not.toContain('SKU-INACTIVE') expect(JSON.stringify(result)).not.toContain('cameraPosition') @@ -321,6 +326,64 @@ describe('ShareService.getShareDetail', () => { }) }) + it('公开详情输出结构化资产副本,旧 attributes 只作为 legacyAttributes', async () => { + mockedShareRepository.findPublicDetailByToken.mockResolvedValue({ + ...baseLink, + product: { + ...baseLink.product, + skus: [ + { + ...baseLink.product.skus[0], + attributes: { color: 'legacy-oak' }, + assetProfile: { + id: 'profile-1', + skuId: 'sku-1', + primarySourceAssetVariantId: 'variant-1', + sourceAssetVariantIds: ['variant-1'], + sourceAssetFamilyId: 'family-1', + sourceAssetSeriesId: 'series-1', + model: 'DBZ-SPJK001', + modelName: 'Desk', + specCode: 'DBZ-SPJ00001', + specName: '1200 teak', + barcode: 'DBZ-SPJ00001N05V01', + size: { width: 1200 }, + color: 'teak', + material: 'wood', + imageUrl: '/images/profile.png', + sourceStatus: 'ACTIVE', + syncedAt: new Date('2026-03-13T00:00:00.000Z'), + lockedFields: [], + lockedReasons: {}, + sourceVariants: [], + createdAt: new Date('2026-03-13T00:00:00.000Z'), + updatedAt: new Date('2026-03-13T00:00:00.000Z'), + }, + }, + ], + }, + } as never) + mockedAssemblyStepRepository.findByProductId.mockResolvedValue([productLevelStep]) + mockedPanelRepository.findByProductId.mockResolvedValue([productLevelPanel]) + + const result = await service.getShareDetail('token-1') + + expect(result.product.skus[0]).toMatchObject({ + id: 'sku-1', + skuCode: 'SKU-001', + name: '测试 SKU', + assetProfile: { + specCode: 'DBZ-SPJ00001', + specName: '1200 teak', + barcode: 'DBZ-SPJ00001N05V01', + color: 'teak', + material: 'wood', + }, + legacyAttributes: { color: 'legacy-oak' }, + }) + expect(result.product.skus[0]).not.toHaveProperty('attributes') + }) + it('过期分享统一按不可用处理,避免泄露 token 状态', async () => { mockedShareRepository.findPublicDetailByToken.mockResolvedValue({ ...baseLink, diff --git a/furniture-3d-system/src/server/services/__tests__/sku-command.service.test.ts b/furniture-3d-system/src/server/services/__tests__/sku-command.service.test.ts index 19629be0..5bdd0a8b 100644 --- a/furniture-3d-system/src/server/services/__tests__/sku-command.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/sku-command.service.test.ts @@ -1,23 +1,79 @@ import { SKUCommandService } from '../sku-command.service' import { releaseSkuCodeFromDeletedRecords } from '../sku-soft-delete.helper' +import { productRepository } from '../../repositories/product.repository' + +jest.mock('../../repositories/product.repository', () => ({ + productRepository: { + existsById: jest.fn(), + findById: jest.fn(), + }, +})) jest.mock('../sku-soft-delete.helper', () => ({ - buildDeletedSkuCode: jest.fn((skuCode: string, id: string) => `${skuCode}__deleted__${id}`), - executeCreateWithSkuCodeRecovery: jest.fn(async ({ create }) => create()), - getRestorableSkuCode: jest.fn((skuCode: string) => skuCode), - releaseSkuCodeFromDeletedRecords: jest.fn(), - toSkuAttributesObject: jest.fn((attributes: unknown) => - attributes && typeof attributes === 'object' && !Array.isArray(attributes) - ? attributes - : {}, + buildRestoreSkuUpdateData: jest.fn((record: { skuCode: string; attributes?: Record }) => ({ + skuCode: record.skuCode, + attributes: Object.fromEntries( + Object.entries(record.attributes ?? {}).filter( + ([key]) => key !== '_originalSkuCode' && key !== '_deletedCodeAt', + ), + ), + })), + buildSoftDeleteSkuUpdateData: jest.fn( + (record: { id: string; skuCode: string; attributes?: Record }, deletedAt: Date) => ({ + deletedAt, + skuCode: `${record.skuCode}__deleted__${record.id}`, + attributes: { + ...(record.attributes ?? {}), + _originalSkuCode: record.attributes?._originalSkuCode ?? record.skuCode, + _deletedCodeAt: deletedAt.toISOString(), + }, + }), ), + executeCreateWithSkuCodeRecovery: jest.fn(async ({ create }) => create()), + mergeSkuRecordAttributesForUpdate: jest.fn((record: { attributes?: unknown }, inputAttributes: unknown) => { + const existingAttributes = record.attributes + const existing = + existingAttributes && typeof existingAttributes === 'object' && !Array.isArray(existingAttributes) + ? (existingAttributes as Record) + : {} + const input = + inputAttributes && typeof inputAttributes === 'object' && !Array.isArray(inputAttributes) + ? (inputAttributes as Record) + : {} + const preserved = Object.fromEntries( + Object.entries(existing).filter( + ([key]) => + key.startsWith('_') || + key === 'assetSkuSnapshot' || + key === 'variantSelectionSummary' || + key === 'bomPreview', + ), + ) + const editable = Object.fromEntries( + Object.entries(input).filter( + ([key]) => + !key.startsWith('_') && + key !== 'assetSkuSnapshot' && + key !== 'variantSelectionSummary' && + key !== 'bomPreview', + ), + ) + return { ...editable, ...preserved } + }), + releaseSkuCodeFromDeletedRecords: jest.fn(), +})) + +jest.mock('../sku-cad-import.helper', () => ({ + createSkuWithImportedHolesTx: jest.fn(async () => ({ id: 'sku-created' })), })) describe('SKUCommandService.updateSKU', () => { const repository = { + create: jest.fn(), findById: jest.fn(), skuCodeExists: jest.fn(), update: jest.fn(), + withTransaction: jest.fn((callback: (tx: unknown) => Promise) => callback({})), } const cache = { del: jest.fn(), @@ -44,10 +100,16 @@ describe('SKUCommandService.updateSKU', () => { beforeEach(() => { jest.clearAllMocks() + ;(productRepository.existsById as jest.Mock).mockResolvedValue(true) + ;(productRepository.findById as jest.Mock).mockResolvedValue({ + id: 'product-1', + deletedAt: null, + }) repository.findById.mockResolvedValue({ id: 'sku-1', productId: 'product-1', skuCode: 'SKU-OLD', + name: '旧SKU名称', ownerId: 'user-1', attributes: { color: 'white', @@ -56,6 +118,7 @@ describe('SKUCommandService.updateSKU', () => { deletedAt: null, }) repository.skuCodeExists.mockResolvedValue(false) + repository.create.mockResolvedValue({ id: 'sku-created' }) repository.update.mockResolvedValue({ id: 'sku-1' }) getSKUById.mockResolvedValue({ id: 'sku-1', @@ -64,6 +127,45 @@ describe('SKUCommandService.updateSKU', () => { }) }) + it('创建普通 SKU 时会写入结构化名称', async () => { + await service.createSKU( + { + productId: 'cm00000000000000000000001', + skuCode: 'SKU-001', + name: ' 云序桌-黑腿版 ', + attributes: { + finishTag: 'matte-black', + }, + price: 99, + }, + ctx, + ) + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + skuCode: 'SKU-001', + name: '云序桌-黑腿版', + attributes: { finishTag: 'matte-black' }, + }), + ) + }) + + it('创建普通 SKU 时阻断 attributes.skuName 继续写入第二套名称真相', async () => { + await expect(service.createSKU( + { + productId: 'cm00000000000000000000001', + skuCode: 'SKU-002', + attributes: { + skuName: '云序桌-白腿版', + }, + price: 99, + }, + ctx, + )).rejects.toThrow('SKU 兼容属性不能写入正式主数据字段:skuName') + + expect(repository.create).not.toHaveBeenCalled() + }) + it('更新 SKU 编码时会校验重复并写入新编码', async () => { await service.updateSKU( 'sku-1', @@ -82,10 +184,13 @@ describe('SKUCommandService.updateSKU', () => { 'sku-1', expect.objectContaining({ skuCode: 'SKU-NEW', - attributes: { - color: 'black', - _cadVersion: 'v1', - }, + name: '旧SKU名称', + }), + ) + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.not.objectContaining({ + attributes: expect.anything(), }), ) }) @@ -105,4 +210,279 @@ describe('SKUCommandService.updateSKU', () => { expect(repository.update).not.toHaveBeenCalled() }) + + it('软删除 SKU 时同步释放资产 profile 条码唯一池', async () => { + const deletedAt = new Date('2026-05-31T12:00:00.000Z') + jest.useFakeTimers().setSystemTime(deletedAt) + const tx = { + productSKU: { + update: jest.fn(), + }, + productSkuAssetProfile: { + updateMany: jest.fn(), + }, + } + repository.withTransaction.mockImplementationOnce( + async (callback: (client: typeof tx) => Promise) => callback(tx), + ) + + try { + await service.deleteSKU('sku-1', ctx) + } finally { + jest.useRealTimers() + } + + expect(tx.productSKU.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sku-1' }, + data: expect.objectContaining({ + deletedAt, + skuCode: expect.stringContaining('__deleted__'), + }), + }), + ) + expect(tx.productSkuAssetProfile.updateMany).toHaveBeenCalledWith({ + where: { skuId: 'sku-1' }, + data: { skuDeletedAt: deletedAt }, + }) + }) + + it('恢复 SKU 时遇到活动资产 profile 条码冲突应阻断', async () => { + repository.findById.mockResolvedValue({ + id: 'sku-1', + productId: 'product-1', + skuCode: 'SKU-OLD__deleted__1770000000000_sku-1', + name: '旧SKU名称', + ownerId: 'user-1', + attributes: { + _originalSkuCode: 'SKU-OLD', + }, + deletedAt: new Date('2026-05-31T12:00:00.000Z'), + }) + ;(productRepository.findById as jest.Mock).mockResolvedValue({ + id: 'product-1', + deletedAt: null, + }) + repository.skuCodeExists.mockResolvedValue(false) + const tx = { + productSKU: { + update: jest.fn(), + }, + productSkuAssetProfile: { + findUnique: jest.fn().mockResolvedValue({ + barcode: ' bc-001 ', + sourceStatus: 'ACTIVE', + }), + findFirst: jest.fn().mockResolvedValue({ skuId: 'sku-2' }), + updateMany: jest.fn(), + }, + } + repository.withTransaction.mockImplementationOnce( + async (callback: (client: typeof tx) => Promise) => callback(tx), + ) + + await expect(service.restoreSKU('sku-1', ctx)).rejects.toThrow( + 'SKU barcode BC-001 is already in use', + ) + expect(tx.productSkuAssetProfile.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + barcode: { equals: 'BC-001', mode: 'insensitive' }, + }), + }), + ) + expect(tx.productSKU.update).not.toHaveBeenCalled() + expect(tx.productSkuAssetProfile.updateMany).not.toHaveBeenCalled() + }) + + it('普通 SKU 更新会忽略 attributes,不能反写结构化名称', async () => { + await service.updateSKU( + 'sku-1', + { + price: 129, + attributes: { + skuName: '新SKU名称', + color: 'black', + }, + }, + ctx, + ) + + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.objectContaining({ + price: 129, + }), + ) + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.not.objectContaining({ + name: '新SKU名称', + attributes: expect.anything(), + }), + ) + }) + + it('普通 SKU 更新即使传 attributes 也不会清掉资产同步快照', async () => { + repository.findById.mockResolvedValue({ + id: 'sku-1', + productId: 'product-1', + skuCode: 'SKU-OLD', + name: '旧SKU名称', + ownerId: 'user-1', + attributes: { + color: 'white', + assetSkuSnapshot: { + specCode: 'DBZ-SPJ00001', + specName: '资产快照名称', + barcode: 'DBZ-SPJ00001N05V01', + }, + _cadVersion: 'v1', + }, + deletedAt: null, + }) + + await service.updateSKU( + 'sku-1', + { + stock: 8, + attributes: { + color: 'black', + }, + }, + ctx, + ) + + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.objectContaining({ + stock: 8, + }), + ) + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.not.objectContaining({ + attributes: expect.anything(), + }), + ) + }) + + it('普通 SKU 更新不能通过 attributes 覆盖内部字段和资产快照', async () => { + repository.findById.mockResolvedValue({ + id: 'sku-1', + productId: 'product-1', + skuCode: 'SKU-OLD', + name: '旧SKU名称', + ownerId: 'user-1', + attributes: { + color: 'white', + _cadVersion: 'v1', + assetSkuSnapshot: { + specCode: 'DBZ-SPJ00001', + barcode: 'DBZ-SPJ00001N05V01', + }, + }, + deletedAt: null, + }) + + await service.updateSKU( + 'sku-1', + { + price: 188, + attributes: { + color: 'black', + _cadVersion: 'v2', + assetSkuSnapshot: 'bad-snapshot', + variantSelectionSummary: 'stale-selection', + bomPreview: 'stale-bom', + }, + }, + ctx, + ) + + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.objectContaining({ + price: 188, + }), + ) + expect(repository.update).toHaveBeenCalledWith( + 'sku-1', + expect.not.objectContaining({ + attributes: expect.anything(), + }), + ) + }) + + it('CAD 导入创建 SKU 也会走统一编码校验', async () => { + await expect( + service.createSKUWithImportedHoles( + { + productId: 'product-1', + skuCode: 'BAD SKU', + attributes: {}, + price: 0, + costPrice: null, + stock: 0, + modelUrl: null, + images: [], + cadVersion: 'v2.2.14', + cadPanels: [], + cadHoles: [], + cadSourceData: null, + }, + ctx, + ), + ).rejects.toThrow('输入验证失败') + + expect(productRepository.existsById).not.toHaveBeenCalled() + expect(repository.skuCodeExists).not.toHaveBeenCalled() + }) + + it('CAD 导入创建 SKU 支持站内模型路径并写入结构化名称', async () => { + getSKUById.mockResolvedValue({ + id: 'sku-created', + productId: 'product-1', + skuCode: 'SKU-CAD-001', + }) + + await service.createSKUWithImportedHoles( + { + productId: 'product-1', + skuCode: 'SKU-CAD-001', + name: ' CAD导入款 ', + attributes: { + _modelMeta: { + originalFileName: 'model.glb', + }, + }, + price: 0, + costPrice: null, + stock: 0, + modelUrl: '/uploads/models/skus/SKU-CAD-001.glb', + images: ['/uploads/images/skus/SKU-CAD-001.png'], + cadVersion: 'v2.2.14', + cadPanels: [ + { + id: 'panel-1', + size: [1200, 600, 25], + }, + ], + cadHoles: [ + { + panelId: 'panel-1', + position: [0, 0, 0], + normal: [0, 1, 0], + diameter: 8, + depth: 12, + }, + ], + cadSourceData: null, + }, + ctx, + ) + + expect(repository.withTransaction).toHaveBeenCalled() + expect(releaseSkuCodeFromDeletedRecords).toHaveBeenCalledWith('SKU-CAD-001') + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/warehouse.service.test.ts b/furniture-3d-system/src/server/services/__tests__/warehouse.service.test.ts index 8c6c411f..84b4e066 100644 --- a/furniture-3d-system/src/server/services/__tests__/warehouse.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/warehouse.service.test.ts @@ -1,6 +1,9 @@ import { ErrorCode } from '@/server/core/errors' jest.mock('../../repositories', () => ({ + assetVariantRepository: { + findById: jest.fn(), + }, packagedItemRepository: { findAllWithRelations: jest.fn(), }, @@ -9,15 +12,22 @@ jest.mock('../../repositories', () => ({ }, warehouseLocationRepository: { findAllActive: jest.fn(), + findById: jest.fn(), findByCode: jest.fn(), + findByPosition: jest.fn(), findBrandMaterialBindingsByBrand: jest.fn(), + findBindingsByVariantIds: jest.fn(), findBindingsByPreSkuIds: jest.fn(), create: jest.fn(), + update: jest.fn(), + upsertBrandMaterialBinding: jest.fn(), + createBindings: jest.fn(), createPreSkuBindings: jest.fn(), }, })) import { + assetVariantRepository, packagedItemRepository, skuRepository, warehouseLocationRepository, @@ -25,6 +35,7 @@ import { import { warehouseService } from '../warehouse.service' describe('warehouseService', () => { + const mockedAssetVariantRepository = jest.mocked(assetVariantRepository) const mockedPackagedItemRepository = jest.mocked(packagedItemRepository) const mockedSkuRepository = jest.mocked(skuRepository) const mockedWarehouseLocationRepository = jest.mocked(warehouseLocationRepository) @@ -51,7 +62,8 @@ describe('warehouseService', () => { { id: 'sku-1', skuCode: 'SPU-001-01', - attributes: { skuName: '云序桌-黑腿版' }, + attributes: { skuName: '旧快照名称' }, + assetProfile: { specName: '云序桌-黑腿版' }, stock: 6, warehouseLocationId: 'loc-1', warehouseLocation: { id: 'loc-1', code: 'A-01', name: '成品区 A01' }, @@ -99,12 +111,85 @@ describe('warehouseService', () => { await expect( warehouseService.createLocation({ - code: ' A-01 ', + code: ' a-01 ', name: ' 成品区 A01 ', }), ).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED, }) + expect(mockedWarehouseLocationRepository.findByCode).toHaveBeenCalledWith('A-01') + }) + + it('updateLocation 会归一化库位编码并在服务层检查重复', async () => { + mockedWarehouseLocationRepository.findById.mockResolvedValue({ + id: 'loc-1', + code: 'A-01', + name: '成品区 A01', + zone: 'A', + aisle: '01', + shelf: '02', + bin: '03', + notes: null, + isActive: true, + } as never) + mockedWarehouseLocationRepository.findByCode.mockResolvedValue(null) + mockedWarehouseLocationRepository.findByPosition.mockResolvedValue(null) + mockedWarehouseLocationRepository.update.mockResolvedValue({ + id: 'loc-1', + code: 'B-02', + name: '成品区 B02', + zone: 'A', + aisle: '01', + shelf: '02', + bin: '03', + notes: null, + isActive: true, + } as never) + + await warehouseService.updateLocation('loc-1', { + code: ' b-02 ', + name: ' 成品区 B02 ', + }) + + expect(mockedWarehouseLocationRepository.findByCode).toHaveBeenCalledWith('B-02') + expect(mockedWarehouseLocationRepository.update).toHaveBeenCalledWith( + 'loc-1', + expect.objectContaining({ + code: 'B-02', + name: '成品区 B02', + }), + ) + }) + + it('updateLocation 会拒绝和其他活动库位重复的物理位置', async () => { + mockedWarehouseLocationRepository.findById.mockResolvedValue({ + id: 'loc-1', + code: 'A-01', + name: '成品区 A01', + zone: 'A', + aisle: '01', + shelf: '02', + bin: '03', + notes: null, + isActive: true, + } as never) + mockedWarehouseLocationRepository.findByPosition.mockResolvedValue({ + id: 'loc-2', + } as never) + + await expect( + warehouseService.updateLocation('loc-1', { + zone: 'B', + aisle: '02', + shelf: '03', + bin: '04', + }), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + + expect(mockedWarehouseLocationRepository.findByPosition).toHaveBeenCalledWith('B', '02', '03', '04', 'loc-1') + expect(mockedWarehouseLocationRepository.update).not.toHaveBeenCalled() }) it('getBrandMaterials 会返回品牌资料和库位信息', async () => { @@ -139,6 +224,47 @@ describe('warehouseService', () => { ]) }) + it('bindBrandMaterialLocation 会规范化品牌物料类型并拒绝游离类型', async () => { + mockedWarehouseLocationRepository.findById.mockResolvedValue({ + id: 'loc-1', + code: 'A-01', + name: '成品区 A01', + isActive: true, + } as never) + mockedWarehouseLocationRepository.upsertBrandMaterialBinding.mockResolvedValue({ + id: 'brand-binding-1', + brand: '顾家', + materialType: 'MANUAL', + warehouseLocation: { id: 'loc-1', code: 'A-01', name: '成品区 A01' }, + fileUrl: null, + fileName: null, + fileSize: null, + } as never) + + await warehouseService.bindBrandMaterialLocation({ + brand: ' 顾家 ', + materialType: ' manual ', + warehouseLocationId: ' loc-1 ', + }) + + expect(mockedWarehouseLocationRepository.findById).toHaveBeenCalledWith('loc-1') + expect(mockedWarehouseLocationRepository.upsertBrandMaterialBinding).toHaveBeenCalledWith({ + brand: '顾家', + materialType: 'MANUAL', + warehouseLocationId: 'loc-1', + }) + + await expect( + warehouseService.bindBrandMaterialLocation({ + brand: '顾家', + materialType: 'UNKNOWN', + warehouseLocationId: null, + }), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + }) + }) + it('bindPreSkuToLocations 会创建绑定并返回最新库位信息', async () => { mockedWarehouseLocationRepository.findAllActive.mockResolvedValue([ { @@ -167,8 +293,8 @@ describe('warehouseService', () => { ] as never) const result = await warehouseService.bindPreSkuToLocations({ - preSkuId: 'pre-sku-1', - locationIds: ['loc-1'], + preSkuId: ' pre-sku-1 ', + locationIds: [' loc-1 ', 'loc-1'], }) expect(mockedWarehouseLocationRepository.createPreSkuBindings).toHaveBeenCalledWith([ @@ -187,4 +313,48 @@ describe('warehouseService', () => { }), ]) }) + + it('bindVariantToLocations 会规范化并去重库位绑定 ID', async () => { + mockedAssetVariantRepository.findById.mockResolvedValue({ + id: 'variant-1', + } as never) + mockedWarehouseLocationRepository.findAllActive.mockResolvedValue([ + { + id: 'loc-1', + code: 'A-01', + name: '成品区 A01', + zone: 'A', + aisle: '01', + shelf: '02', + bin: '03', + notes: null, + isActive: true, + }, + ] as never) + mockedWarehouseLocationRepository.createBindings.mockResolvedValue(undefined as never) + mockedWarehouseLocationRepository.findBindingsByVariantIds.mockResolvedValue([ + { + id: 'binding-variant-1', + assetVariantId: 'variant-1', + warehouseLocation: { + id: 'loc-1', + code: 'A-01', + name: '成品区 A01', + }, + }, + ] as never) + + await warehouseService.bindVariantToLocations({ + variantId: ' variant-1 ', + locationIds: [' loc-1 ', 'loc-1'], + }) + + expect(mockedAssetVariantRepository.findById).toHaveBeenCalledWith('variant-1') + expect(mockedWarehouseLocationRepository.createBindings).toHaveBeenCalledWith([ + { + assetVariantId: 'variant-1', + warehouseLocationId: 'loc-1', + }, + ]) + }) }) diff --git a/furniture-3d-system/src/server/services/approval-workflow.service.ts b/furniture-3d-system/src/server/services/approval-workflow.service.ts index a389f32c..37c26d2b 100644 --- a/furniture-3d-system/src/server/services/approval-workflow.service.ts +++ b/furniture-3d-system/src/server/services/approval-workflow.service.ts @@ -4,10 +4,8 @@ import type { AuthUser } from '@/server/core/types' import { canCreateContent } from '@/lib/role-scope' import { Prisma, type ApprovalWorkflowStatus, type BusinessAccountType } from '@prisma/client' import { - getNonIntegratedFormalSpu, normalizeNonIntegratedOpeningReference, normalizeNonIntegratedProductRecord, - type NonIntegratedOpeningReference, type NonIntegratedProductRecord, } from '@/app/intake/non-integrated/nonIntegratedStorage' @@ -43,6 +41,10 @@ type WorkflowForDTO = { id: string spu: string payload: Prisma.JsonValue + profile: { + productName: string + productImageUrl: string | null + } | null } initiator: { id: string @@ -53,15 +55,6 @@ type WorkflowForDTO = { updatedAt: Date } -type ProductIdentitySource = { - id: string - spu: string - openingReference: NonIntegratedOpeningReference - documentWorkspaceConfirmations?: NonIntegratedProductRecord['documentWorkspaceConfirmations'] - documentWorkspaceApprovedAt?: NonIntegratedProductRecord['documentWorkspaceApprovedAt'] - formalSpuAssigned?: boolean -} - export interface ApprovalWorkflowDTO { id: string approvalNumber: string @@ -306,40 +299,94 @@ const BUSINESS_ROLE_SET = new Set([ 'SUPPLIER', ]) +const APPROVAL_WORKFLOW_STATUS_SET = new Set([ + 'DRAFT', + 'PENDING', + 'IN_PROGRESS', + 'COMPLETED', + 'REJECTED', + 'ARCHIVED', +]) + +function normalizeRequiredText(value: unknown, fieldLabel: string) { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}必须填写`) + } + const normalized = value.trim() + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}必须填写`) + } + return normalized +} + +function normalizeOptionalText(value: unknown) { + if (value === undefined || value === null) return null + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized || null +} + +function normalizeApprovalNumber(value: unknown) { + return normalizeRequiredText(value, '审批编号').toUpperCase() +} + +function normalizeParticipantUserIds(value: unknown) { + if (!Array.isArray(value)) return [] + const normalized = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + return [...new Set(normalized)] +} + +function normalizeJsonObjectOrNull(value: Prisma.InputJsonValue | null | undefined, fieldLabel: string) { + if (value === undefined || value === null) return Prisma.JsonNull + if (typeof value === 'object' && !Array.isArray(value)) return value + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}必须是对象`) +} + +function normalizeApprovalStatus(value: ApprovalWorkflowStatus | undefined) { + if (value === undefined) return 'DRAFT' + if (!APPROVAL_WORKFLOW_STATUS_SET.has(value)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '流程状态不合法') + } + return value +} + class ApprovalWorkflowService { private toProductDTO(product: WorkflowForDTO['product']): ApprovalWorkflowProductDTO { const normalizedProduct = normalizeNonIntegratedProductRecord( product.payload as Partial, ) - const openingReference = normalizeNonIntegratedOpeningReference( + const hasStructuredProfile = Boolean(product.profile) + const legacyOpeningReference = normalizeNonIntegratedOpeningReference( normalizedProduct?.openingReference, ) - const productSource: ProductIdentitySource = normalizedProduct ?? { - id: product.id, - spu: product.spu, - openingReference, - } + const openingReference = hasStructuredProfile + ? normalizeNonIntegratedOpeningReference(undefined) + : legacyOpeningReference const modelNumber = - getNonIntegratedFormalSpu(productSource) || - openingReference.temporaryCode.trim() || product.spu || + openingReference.temporaryCode.trim() || product.id const name = + product.profile?.productName.trim() || openingReference.productName.trim() || - normalizedProduct?.spu.trim() || - openingReference.temporaryCode.trim() || product.spu || + openingReference.temporaryCode.trim() || product.id - const thumbnailUrl = - openingReference.productImage.trim() || - normalizedProduct?.previewImage?.trim() || - null + const legacyThumbnailUrl = hasStructuredProfile + ? null + : openingReference.productImage.trim() || + normalizedProduct?.previewImage?.trim() || + null + const thumbnailUrl = product.profile?.productImageUrl?.trim() || legacyThumbnailUrl return { id: product.id, modelNumber, name, - description: openingReference.reason.trim() || null, + description: hasStructuredProfile ? null : openingReference.reason.trim() || null, thumbnailUrl, } } @@ -353,6 +400,12 @@ class ApprovalWorkflowService { id: true, spu: true, payload: true, + profile: { + select: { + productName: true, + productImageUrl: true, + }, + }, }, }, initiator: { @@ -375,6 +428,12 @@ class ApprovalWorkflowService { id: true, spu: true, payload: true, + profile: { + select: { + productName: true, + productImageUrl: true, + }, + }, }, }, initiator: { @@ -403,9 +462,6 @@ class ApprovalWorkflowService { private toDTO(workflow: WorkflowForDTO): ApprovalWorkflowDTO { const matrix = this.getFieldMatrix(workflow.fieldMatrix) - const businessRoles = workflow.businessRoles.length > 0 - ? workflow.businessRoles - : this.getWorkflowBusinessRoles(matrix) return { id: workflow.id, @@ -414,7 +470,7 @@ class ApprovalWorkflowService { status: workflow.status, productId: workflow.productId, accessKey: workflow.accessKey, - businessRoles, + businessRoles: workflow.businessRoles, participantUserIds: workflow.participantUserIds, initiatorId: workflow.initiatorId, lastSyncedAt: workflow.lastSyncedAt?.toISOString() ?? null, @@ -431,29 +487,36 @@ class ApprovalWorkflowService { private getFieldMatrix(value: Prisma.JsonValue | null): FieldMatrixItem[] { if (!Array.isArray(value)) return DEFAULT_FIELD_MATRIX const items: FieldMatrixItem[] = [] + const usedKeys = new Set() for (const item of value) { if (!item || typeof item !== 'object') continue const record = item as Record + const key = typeof record.key === 'string' ? record.key.trim() : '' + const label = typeof record.label === 'string' ? record.label.trim() : '' if ( - typeof record.key !== 'string' || - typeof record.label !== 'string' || + !key || + !label || + usedKeys.has(key) || typeof record.businessRole !== 'string' || !BUSINESS_ROLE_SET.has(record.businessRole as BusinessAccountType) ) { continue } + usedKeys.add(key) items.push({ - key: record.key, - label: record.label, + key, + label, businessRole: record.businessRole as BusinessAccountType, - section: typeof record.section === 'string' ? record.section : undefined, - helperText: typeof record.helperText === 'string' ? record.helperText : undefined, + section: normalizeOptionalText(record.section) ?? undefined, + helperText: normalizeOptionalText(record.helperText) ?? undefined, required: record.required === true, editableStatuses: Array.isArray(record.editableStatuses) - ? record.editableStatuses.filter((status): status is ApprovalWorkflowStatus => typeof status === 'string') + ? record.editableStatuses.filter((status): status is ApprovalWorkflowStatus => + typeof status === 'string' && APPROVAL_WORKFLOW_STATUS_SET.has(status as ApprovalWorkflowStatus), + ) : undefined, allowSecondEdit: record.allowSecondEdit !== false, - placeholder: typeof record.placeholder === 'string' ? record.placeholder : undefined, + placeholder: normalizeOptionalText(record.placeholder) ?? undefined, }) } if (items.length === 0) return DEFAULT_FIELD_MATRIX @@ -483,11 +546,8 @@ class ApprovalWorkflowService { if (currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN') return if (workflow.participantUserIds.includes(currentUser.id)) return - const workflowBusinessRoles = workflow.businessRoles.length > 0 - ? workflow.businessRoles - : this.getWorkflowBusinessRoles(this.getFieldMatrix(workflow.fieldMatrix ?? null)) const userBusinessRoles = currentUser.businessRoles ?? [] - if (workflowBusinessRoles.some((role) => userBusinessRoles.includes(role))) return + if (workflow.businessRoles.some((role) => userBusinessRoles.includes(role))) return throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不在该流程的参与范围内') } @@ -506,22 +566,29 @@ class ApprovalWorkflowService { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有内部业务写入权限') } + const approvalNumber = normalizeApprovalNumber(input.approvalNumber) + const title = normalizeRequiredText(input.title, '流程标题') + const productId = normalizeRequiredText(input.productId, '产品 ID') + const status = normalizeApprovalStatus(input.status) + const participantUserIds = normalizeParticipantUserIds(input.participantUserIds) const matrix = this.getFieldMatrix((input.fieldMatrix ?? null) as Prisma.JsonValue | null) const businessRoles = this.getWorkflowBusinessRoles(matrix) + const summary = normalizeJsonObjectOrNull(input.summary, '流程摘要') + const metadata = normalizeJsonObjectOrNull(input.metadata, '流程扩展信息') const created = await prisma.approvalWorkflow.create({ data: { - approvalNumber: input.approvalNumber.trim(), - title: input.title.trim(), - productId: input.productId, - status: input.status ?? 'DRAFT', + approvalNumber, + title, + productId, + status, accessKey: crypto.randomUUID(), businessRoles, - participantUserIds: input.participantUserIds ?? [], + participantUserIds, initiatorId: currentUser.id, fieldMatrix: matrix as unknown as Prisma.InputJsonValue, - summary: input.summary ?? Prisma.JsonNull, - metadata: input.metadata ?? Prisma.JsonNull, + summary, + metadata, }, }) @@ -553,11 +620,13 @@ class ApprovalWorkflowService { keyword?: string }): Promise { const keyword = options.keyword?.trim() + const approvalNumber = options.approvalNumber ? normalizeApprovalNumber(options.approvalNumber) : undefined + const status = options.status ? normalizeApprovalStatus(options.status) : undefined const workflows = await prisma.approvalWorkflow.findMany({ where: { - productId: options.productId, - approvalNumber: options.approvalNumber, - status: options.status, + productId: options.productId ? normalizeRequiredText(options.productId, '产品 ID') : undefined, + approvalNumber, + status, ...(keyword ? { OR: [ @@ -568,6 +637,13 @@ class ApprovalWorkflowService { is: { OR: [ { spu: { contains: keyword, mode: 'insensitive' } }, + { + profile: { + is: { + productName: { contains: keyword, mode: 'insensitive' }, + }, + }, + }, ], }, }, @@ -583,6 +659,12 @@ class ApprovalWorkflowService { id: true, spu: true, payload: true, + profile: { + select: { + productName: true, + productImageUrl: true, + }, + }, }, }, initiator: { @@ -600,13 +682,19 @@ class ApprovalWorkflowService { async getByApprovalNumber(approvalNumber: string): Promise { const workflow = await prisma.approvalWorkflow.findUnique({ - where: { approvalNumber }, + where: { approvalNumber: normalizeApprovalNumber(approvalNumber) }, include: { product: { select: { id: true, spu: true, payload: true, + profile: { + select: { + productName: true, + productImageUrl: true, + }, + }, }, }, initiator: { @@ -628,7 +716,7 @@ class ApprovalWorkflowService { accessKey: string, currentUser: AuthUser & { businessRoles?: BusinessAccountType[] } ): Promise> }> { - const workflow = await this.findWorkflowByAccessKey(accessKey) + const workflow = await this.findWorkflowByAccessKey(normalizeRequiredText(accessKey, '流程访问密钥')) if (!workflow) { throw new AppError(ErrorCode.VALIDATION_FAILED, '流程链接不存在') } @@ -662,8 +750,9 @@ class ApprovalWorkflowService { }, currentUser: AuthUser & { businessRoles?: BusinessAccountType[] } ): Promise { + const normalizedWorkflowId = normalizeRequiredText(workflowId, '流程 ID') const workflow = await prisma.approvalWorkflow.findUnique({ - where: { id: workflowId }, + where: { id: normalizedWorkflowId }, }) if (!workflow) { throw new AppError(ErrorCode.VALIDATION_FAILED, '流程记录不存在') @@ -671,8 +760,13 @@ class ApprovalWorkflowService { this.assertWorkflowAccess(workflow, currentUser) + const fieldKey = normalizeRequiredText(input.fieldKey, '流程字段') + const source = input.source === undefined + ? 'workflow-page' + : normalizeRequiredText(input.source, '字段变更来源') + const sourcePage = normalizeOptionalText(input.sourcePage) const matrix = this.getFieldMatrix(workflow.fieldMatrix) - const targetField = matrix.find((item) => item.key === input.fieldKey) + const targetField = matrix.find((item) => item.key === fieldKey) if (!targetField) { throw new AppError(ErrorCode.VALIDATION_FAILED, '字段未进入该流程的责任矩阵') } @@ -691,16 +785,16 @@ class ApprovalWorkflowService { const summary = typeof workflow.summary === 'object' && workflow.summary && !Array.isArray(workflow.summary) ? { ...(workflow.summary as Record) } : {} - const oldValue = (summary[input.fieldKey] ?? null) as Prisma.JsonValue | null + const oldValue = (summary[fieldKey] ?? null) as Prisma.JsonValue | null if (targetField.allowSecondEdit === false && oldValue !== null && oldValue !== undefined) { throw new AppError(ErrorCode.VALIDATION_FAILED, '该字段只允许首次填写') } - summary[input.fieldKey] = input.value + summary[fieldKey] = input.value const updatedId = await prisma.$transaction(async (tx) => { await tx.approvalWorkflow.update({ - where: { id: workflowId }, + where: { id: normalizedWorkflowId }, data: { summary: summary as Prisma.InputJsonValue, lastSyncedAt: new Date(), @@ -709,13 +803,13 @@ class ApprovalWorkflowService { await tx.approvalWorkflowFieldChange.create({ data: { - workflowId, + workflowId: normalizedWorkflowId, userId: currentUser.id, - fieldKey: input.fieldKey, + fieldKey, oldValue: oldValue ?? Prisma.JsonNull, newValue: input.value ?? Prisma.JsonNull, - source: input.source ?? 'workflow-page', - sourcePage: input.sourcePage ?? null, + source, + sourcePage, fromDingtalkPage: input.fromDingtalkPage ?? false, }, }) @@ -725,21 +819,21 @@ class ApprovalWorkflowService { userId: currentUser.id, type: 'PRODUCT_UPDATE', targetType: 'ApprovalWorkflow', - targetId: workflowId, + targetId: normalizedWorkflowId, action: `更新流程字段: ${targetField.label}`, changes: { - fieldKey: input.fieldKey, + fieldKey, oldValue, newValue: input.value ?? null, }, metadata: { - source: input.source ?? 'workflow-page', + source, approvalNumber: workflow.approvalNumber, }, }, }) - return workflowId + return normalizedWorkflowId }) const updated = await this.findWorkflowById(updatedId) diff --git a/furniture-3d-system/src/server/services/asset-cad-import.service.ts b/furniture-3d-system/src/server/services/asset-cad-import.service.ts index 2564af27..a4ff9d56 100644 --- a/furniture-3d-system/src/server/services/asset-cad-import.service.ts +++ b/furniture-3d-system/src/server/services/asset-cad-import.service.ts @@ -1,5 +1,6 @@ import { validateCADExportData, type CADExportData } from '@/lib/cad-export-processor' import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { uploadToS3 } from '@/lib/storage' import { buildPersistedSkuCadData, type PersistedSkuCadData } from '@/lib/sku-cad-data' import { parseZipFile } from '@/lib/zip-parser' @@ -60,6 +61,11 @@ function trimOrNull(value: string | null | undefined): string | null { return next ? next : null } +function normalizeIdentityOrNull(value: string | null | undefined): string | null { + const next = normalizeCoreIdentity(value) + return next ? next : null +} + function sanitizeSegment(value: string): string { const normalized = value.trim().replace(/[^a-zA-Z0-9-_]/g, '_') return normalized.length > 0 ? normalized : 'asset' @@ -318,18 +324,20 @@ function resolveImportNames(params: { zipFileName: params.zipFileName, cadExportData: params.cadExportData, }) - const familyCodeInput = trimOrNull(params.input.familyCode) - const variantCodeInput = trimOrNull(params.input.variantCode) + const familyCodeInput = normalizeIdentityOrNull(params.input.familyCode) + const variantCodeInput = normalizeIdentityOrNull(params.input.variantCode) const shouldUseSeriesGeneratedCode = Boolean(trimOrNull(params.input.assetSeriesId)) + const inferredFamilyCode = normalizeIdentityOrNull(inferred.familyCode) + const inferredVariantCode = normalizeIdentityOrNull(inferred.variantCode) return { familyCode: params.input.familyId ? null - : familyCodeInput ?? (shouldUseSeriesGeneratedCode ? null : inferred.familyCode), + : familyCodeInput ?? (shouldUseSeriesGeneratedCode ? null : inferredFamilyCode), familyName: params.input.familyId ? null : trimOrNull(params.input.familyName) ?? inferred.familyName, - variantCode: variantCodeInput ?? (shouldUseSeriesGeneratedCode ? null : inferred.variantCode), + variantCode: variantCodeInput ?? (shouldUseSeriesGeneratedCode ? null : inferredVariantCode), variantName: trimOrNull(params.input.variantName) ?? inferred.variantName, } } diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index 5ed15ccb..7d1449ec 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -152,6 +152,40 @@ function trimOrNull(value: string | null | undefined): string | null { return next ? next : null } +function normalizeRequiredText(value: string | null | undefined, fieldName: string): string { + const next = value?.trim() ?? '' + if (!next) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldName}不能为空`) + } + return next +} + +function normalizeNonNegativeInteger( + value: number | null | undefined, + fieldName: string, +): number | undefined { + if (value === undefined || value === null) return undefined + if (!Number.isInteger(value) || value < 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须是非负整数`) + } + return value +} + +function normalizePositiveNumber( + value: number | null | undefined, + fieldName: string, +): number | undefined { + if (value === undefined || value === null) return undefined + if (!Number.isFinite(value) || value <= 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须是大于 0 的有效数字`) + } + return value +} + +function normalizeCoreCode(value: string | null | undefined): string { + return value?.trim().toUpperCase() ?? '' +} + const STALE_SERIES_DESCRIPTION_PATTERN = /(固定模板|自动连接件推荐|自动拼装|自动组合|auto-combinations)/i @@ -786,26 +820,6 @@ async function ensureActiveSeries( return series } -async function ensureActiveSeriesIds( - ids: string[] | undefined, - options?: { required?: boolean }, -): Promise { - const normalizedIds = Array.from(new Set((ids ?? []).map((id) => id.trim()).filter(Boolean))) - if (normalizedIds.length === 0) { - if (options?.required) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择至少一个兼容系列') - } - return [] - } - - const existing = await assetSeriesRepository.findByIds(normalizedIds) - if (existing.length !== normalizedIds.length) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '兼容系列不存在或已停用') - } - - return normalizedIds -} - async function ensureVariantCodeAvailable(code: string, currentId?: string): Promise { const existing = await assetVariantRepository.findByCode(code) if (existing && existing.id !== currentId) { @@ -1476,7 +1490,7 @@ class AssetCenterService { throw new AppError(ErrorCode.VALIDATION_FAILED, '基材库已有相同组合的基材,不可重复创建') } - const code = input.code?.trim() || (await this.generateNextSubstrateCode()) + const code = normalizeCoreCode(input.code) || (await this.generateNextSubstrateCode()) if (await substrateRepository.findByCode(code)) { throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在') } @@ -1530,7 +1544,7 @@ class AssetCenterService { throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在') } - const nextCode = input.code?.trim() ?? existing.code + const nextCode = input.code === undefined ? existing.code : normalizeCoreCode(input.code) if (!nextCode) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码不能为空') } @@ -1642,14 +1656,12 @@ class AssetCenterService { } async createSubstrateDictionary(input: CreateSubstrateDictionaryInput): Promise { - const name = input.name.trim() - if (!name) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${getDictionaryLabel(input.type)}名称不能为空`) - } + const name = normalizeRequiredText(input.name, `${getDictionaryLabel(input.type)}名称`) + const sortOrder = normalizeNonNegativeInteger(input.sortOrder, `${getDictionaryLabel(input.type)}排序`) const dataBase = { name, - sortOrder: input.sortOrder ?? 0, + sortOrder: sortOrder ?? 0, isActive: input.isActive ?? true, } @@ -1666,17 +1678,20 @@ class AssetCenterService { await prisma.substrateEnvironmentGrade.create({ data: dataBase }), ) case 'THICKNESS_SPEC': - if (!input.thicknessMm || input.thicknessMm <= 0) { + if (input.thicknessMm === undefined || input.thicknessMm === null) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '厚度规格必须填写厚度 mm') } + const thicknessMm = normalizePositiveNumber(input.thicknessMm, '厚度 mm') + const sheetLengthMm = normalizePositiveNumber(input.sheetLengthMm, '板长 mm') ?? 2440 + const sheetWidthMm = normalizePositiveNumber(input.sheetWidthMm, '板宽 mm') ?? 1220 return toDictionaryOptionDTO( 'THICKNESS_SPEC', await prisma.substrateThicknessSpec.create({ data: { ...dataBase, - thicknessMm: input.thicknessMm, - sheetLengthMm: input.sheetLengthMm ?? 2440, - sheetWidthMm: input.sheetWidthMm ?? 1220, + thicknessMm: thicknessMm!, + sheetLengthMm, + sheetWidthMm, }, }), ) @@ -1707,6 +1722,14 @@ class AssetCenterService { id: string, input: UpdateSubstrateDictionaryInput, ): Promise { + const name = input.name === undefined + ? undefined + : normalizeRequiredText(input.name, `${getDictionaryLabel(type)}名称`) + const sortOrder = normalizeNonNegativeInteger(input.sortOrder, `${getDictionaryLabel(type)}排序`) + const thicknessMm = normalizePositiveNumber(input.thicknessMm, '厚度 mm') + const sheetLengthMm = normalizePositiveNumber(input.sheetLengthMm, '板长 mm') + const sheetWidthMm = normalizePositiveNumber(input.sheetWidthMm, '板宽 mm') + try { switch (type) { case 'CATEGORY': @@ -1715,8 +1738,8 @@ class AssetCenterService { await prisma.substrateCategory.update({ where: { id }, data: { - name: input.name?.trim(), - sortOrder: input.sortOrder, + name, + sortOrder, isActive: input.isActive, }, }), @@ -1727,8 +1750,8 @@ class AssetCenterService { await prisma.substrateEnvironmentGrade.update({ where: { id }, data: { - name: input.name?.trim(), - sortOrder: input.sortOrder, + name, + sortOrder, isActive: input.isActive, }, }), @@ -1739,12 +1762,12 @@ class AssetCenterService { await prisma.substrateThicknessSpec.update({ where: { id }, data: { - name: input.name?.trim(), - sortOrder: input.sortOrder, + name, + sortOrder, isActive: input.isActive, - thicknessMm: input.thicknessMm ?? undefined, - sheetLengthMm: input.sheetLengthMm ?? undefined, - sheetWidthMm: input.sheetWidthMm ?? undefined, + thicknessMm, + sheetLengthMm, + sheetWidthMm, }, }), ) @@ -1754,8 +1777,8 @@ class AssetCenterService { await prisma.substrateSurfaceOption.update({ where: { id }, data: { - name: input.name?.trim(), - sortOrder: input.sortOrder, + name, + sortOrder, isActive: input.isActive, }, }), @@ -1766,8 +1789,8 @@ class AssetCenterService { await prisma.substrateSystemNameOption.update({ where: { id }, data: { - name: input.name?.trim(), - sortOrder: input.sortOrder, + name, + sortOrder, isActive: input.isActive, }, }), diff --git a/furniture-3d-system/src/server/services/asset-sku-snapshot.ts b/furniture-3d-system/src/server/services/asset-sku-snapshot.ts index 9f892ebf..ab26ad86 100644 --- a/furniture-3d-system/src/server/services/asset-sku-snapshot.ts +++ b/furniture-3d-system/src/server/services/asset-sku-snapshot.ts @@ -1,3 +1,5 @@ +import { Prisma } from '@prisma/client' +import type { ProductSkuAssetProfile } from '@prisma/client' import type { AssetVariantWithRelations } from '../repositories' export type AssetSkuSnapshotSourceStatus = 'ACTIVE' | 'DELETED' | 'MISSING' @@ -34,6 +36,60 @@ function readString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null } +function readIdentityString(value: unknown): string | null { + const text = readString(value) + return text ? text.toUpperCase() : null +} + +function normalizeSnapshotVariant(variant: AssetSkuSnapshotVariant): AssetSkuSnapshotVariant { + return { + ...variant, + sourceAssetVariantId: variant.sourceAssetVariantId.trim(), + sourceAssetFamilyId: readString(variant.sourceAssetFamilyId), + sourceAssetSeriesId: readString(variant.sourceAssetSeriesId), + model: readIdentityString(variant.model), + modelName: readString(variant.modelName), + specCode: readIdentityString(variant.specCode), + specName: readString(variant.specName), + barcode: readIdentityString(variant.barcode), + color: readString(variant.color), + material: readString(variant.material), + imageUrl: readString(variant.imageUrl), + } +} + +function normalizeSnapshot(snapshot: AssetSkuSnapshot): AssetSkuSnapshot { + const sourceVariants = snapshot.sourceVariants.map((variant) => normalizeSnapshotVariant(variant)) + const sourceAssetVariantIds = Array.from( + new Set( + [ + snapshot.sourceAssetVariantId, + ...snapshot.sourceAssetVariantIds, + ...sourceVariants.map((variant) => variant.sourceAssetVariantId), + ] + .map((variantId) => variantId.trim()) + .filter((variantId) => variantId.length > 0), + ), + ) + + return { + ...snapshot, + sourceAssetVariantId: snapshot.sourceAssetVariantId.trim(), + sourceAssetFamilyId: readString(snapshot.sourceAssetFamilyId), + sourceAssetSeriesId: readString(snapshot.sourceAssetSeriesId), + sourceVariants, + sourceAssetVariantIds, + model: readIdentityString(snapshot.model), + modelName: readString(snapshot.modelName), + specCode: readIdentityString(snapshot.specCode), + specName: readString(snapshot.specName), + barcode: readIdentityString(snapshot.barcode), + color: readString(snapshot.color), + material: readString(snapshot.material), + imageUrl: readString(snapshot.imageUrl), + } +} + function readLockedFields(value: unknown): string[] { if (!isRecord(value) || !Array.isArray(value.lockedFields)) { return [] @@ -52,6 +108,42 @@ function readLockedReasons(value: unknown): Record { ) } +function readSourceStatus(value: unknown): AssetSkuSnapshotSourceStatus { + return value === 'DELETED' || value === 'MISSING' ? value : 'ACTIVE' +} + +function readNumberMap(value: unknown): Record { + if (!isRecord(value)) return {} + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]), + ), + ) +} + +function readSnapshotVariant(value: unknown): AssetSkuSnapshotVariant | null { + if (!isRecord(value)) return null + + const sourceAssetVariantId = readString(value.sourceAssetVariantId) + if (!sourceAssetVariantId) return null + + return { + sourceAssetVariantId, + sourceAssetFamilyId: readString(value.sourceAssetFamilyId), + sourceAssetSeriesId: readString(value.sourceAssetSeriesId), + model: readIdentityString(value.model), + modelName: readString(value.modelName), + specCode: readIdentityString(value.specCode), + specName: readString(value.specName), + barcode: readIdentityString(value.barcode), + size: readNumberMap(value.size), + color: readString(value.color), + material: readString(value.material), + imageUrl: readString(value.imageUrl), + sourceStatus: readSourceStatus(value.sourceStatus), + } +} + function readMaterialLabel(record: AssetVariantWithRelations): string | null { const businessData = isRecord(record.businessData) ? record.businessData : null const materialLabel = readString(businessData?.materialLabel) @@ -62,11 +154,55 @@ function readMaterialLabel(record: AssetVariantWithRelations): string | null { return record.substrate?.name ?? record.finish?.name ?? null } +export function buildAssetSkuSnapshotFromProfile( + profile: ProductSkuAssetProfile | null | undefined, +): AssetSkuSnapshot | null { + if (!profile) return null + + const primaryVariant: AssetSkuSnapshotVariant = { + sourceAssetVariantId: profile.primarySourceAssetVariantId ?? '', + sourceAssetFamilyId: profile.sourceAssetFamilyId, + sourceAssetSeriesId: profile.sourceAssetSeriesId, + model: readIdentityString(profile.model), + modelName: profile.modelName, + specCode: readIdentityString(profile.specCode), + specName: profile.specName, + barcode: readIdentityString(profile.barcode), + size: readNumberMap(profile.size), + color: profile.color, + material: profile.material, + imageUrl: profile.imageUrl, + sourceStatus: readSourceStatus(profile.sourceStatus), + } + + if (!primaryVariant.sourceAssetVariantId) { + return null + } + + const sourceVariants = Array.isArray(profile.sourceVariants) + ? profile.sourceVariants + .map((item) => readSnapshotVariant(item)) + .filter((item): item is AssetSkuSnapshotVariant => item !== null) + : [] + const sourceAssetVariantIds = profile.sourceAssetVariantIds.length > 0 + ? profile.sourceAssetVariantIds + : [primaryVariant.sourceAssetVariantId] + + return normalizeSnapshot({ + ...primaryVariant, + sourceAssetVariantIds, + sourceVariants: sourceVariants.length > 0 ? sourceVariants : [primaryVariant], + syncedAt: profile.syncedAt.toISOString(), + lockedFields: profile.lockedFields, + lockedReasons: readLockedReasons(profile.lockedReasons), + }) +} + export function buildAssetSkuSnapshotVariant( record: AssetVariantWithRelations, ): AssetSkuSnapshotVariant { const assemblyBasis = isRecord(record.assemblyBasis) ? record.assemblyBasis : null - const familyCode = readString(record.assetFamily?.code) ?? readString(assemblyBasis?.assetFamilyCode) + const familyCode = readIdentityString(record.assetFamily?.code) ?? readIdentityString(assemblyBasis?.assetFamilyCode) const familyName = readString(record.assetFamily?.name) ?? readString(assemblyBasis?.assetFamilyName) return { @@ -75,9 +211,9 @@ export function buildAssetSkuSnapshotVariant( sourceAssetSeriesId: record.assetFamily?.assetSeriesId ?? null, model: familyCode, modelName: familyName, - specCode: record.code, + specCode: readIdentityString(record.code), specName: record.name, - barcode: record.barcode ?? readString(assemblyBasis?.barcode), + barcode: readIdentityString(record.barcode) ?? readIdentityString(assemblyBasis?.barcode), size: isRecord(record.dimensions) ? (record.dimensions as Record) : {}, color: record.finish?.name ?? readString(assemblyBasis?.finishName), material: readMaterialLabel(record), @@ -128,14 +264,14 @@ export function buildAssetSkuSnapshot(input: { sourceVariants[0] if (!primary) return null - return { + return normalizeSnapshot({ ...primary, sourceAssetVariantIds: sourceVariants.map((variant) => variant.sourceAssetVariantId), sourceVariants, syncedAt: (input.syncedAt ?? new Date()).toISOString(), lockedFields: readLockedFields(input.previousSnapshot), lockedReasons: readLockedReasons(input.previousSnapshot), - } + }) } export function applyLockedSnapshotFields(input: { @@ -167,5 +303,34 @@ export function applyLockedSnapshotFields(input: { } }) - return merged + return normalizeSnapshot(merged) +} + +function parseSnapshotSyncedAt(value: string): Date { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? new Date() : parsed +} + +export function buildProductSkuAssetProfileData(snapshot: AssetSkuSnapshot) { + const normalizedSnapshot = normalizeSnapshot(snapshot) + return { + primarySourceAssetVariantId: normalizedSnapshot.sourceAssetVariantId, + sourceAssetVariantIds: normalizedSnapshot.sourceAssetVariantIds, + sourceAssetFamilyId: normalizedSnapshot.sourceAssetFamilyId, + sourceAssetSeriesId: normalizedSnapshot.sourceAssetSeriesId, + model: normalizedSnapshot.model, + modelName: normalizedSnapshot.modelName, + specCode: normalizedSnapshot.specCode, + specName: normalizedSnapshot.specName, + barcode: normalizedSnapshot.barcode, + size: normalizedSnapshot.size as Prisma.InputJsonValue, + color: normalizedSnapshot.color, + material: normalizedSnapshot.material, + imageUrl: normalizedSnapshot.imageUrl, + sourceStatus: normalizedSnapshot.sourceStatus, + syncedAt: parseSnapshotSyncedAt(normalizedSnapshot.syncedAt), + lockedFields: normalizedSnapshot.lockedFields, + lockedReasons: normalizedSnapshot.lockedReasons as Prisma.InputJsonValue, + sourceVariants: normalizedSnapshot.sourceVariants as unknown as Prisma.InputJsonValue, + } } diff --git a/furniture-3d-system/src/server/services/asset-sku-sync.service.ts b/furniture-3d-system/src/server/services/asset-sku-sync.service.ts index 5e4eef1c..695cba2a 100644 --- a/furniture-3d-system/src/server/services/asset-sku-sync.service.ts +++ b/furniture-3d-system/src/server/services/asset-sku-sync.service.ts @@ -8,8 +8,10 @@ import { assetVariantRelationInclude, type AssetVariantWithRelations } from '../ import { applyLockedSnapshotFields, buildAssetSkuSnapshot, + buildAssetSkuSnapshotFromProfile, buildMissingAssetSkuSnapshotVariant, buildAssetSkuSnapshotVariant, + buildProductSkuAssetProfileData, type AssetSkuSnapshot, } from './asset-sku-snapshot' @@ -30,10 +32,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function readAssetSkuSnapshot(attributes: unknown): unknown { - return isRecord(attributes) ? attributes.assetSkuSnapshot : null -} - function isImageLocked(snapshot: AssetSkuSnapshot | null): boolean { return Boolean(snapshot?.lockedFields.includes('imageUrl') || snapshot?.lockedFields.includes('images')) } @@ -214,16 +212,14 @@ class AssetSkuSyncService { images: true, sourceAssetVariantId: true, sourceAssetVariantIds: true, + assetProfile: true, }, }) let syncedCount = 0 for (const sku of skus) { const existingAttributes = isRecord(sku.attributes) ? sku.attributes : {} - const existingSnapshot = readAssetSkuSnapshot(existingAttributes) - const previousSnapshot = isRecord(existingSnapshot) - ? existingSnapshot as unknown as AssetSkuSnapshot - : null + const previousSnapshot = buildAssetSkuSnapshotFromProfile(sku.assetProfile) const primarySourceVariantId = sku.sourceAssetVariantId ?? previousSnapshot?.sourceAssetVariantId ?? @@ -244,7 +240,7 @@ class AssetSkuSyncService { variants: variant ? [variant] : [], primaryVariantId: primarySourceVariantId, missingVariantIds: variant ? [] : [event.assetVariantId], - previousSnapshot: existingSnapshot, + previousSnapshot, }) if (!currentSnapshot) { continue @@ -261,7 +257,7 @@ class AssetSkuSyncService { : currentSnapshot.sourceAssetVariantIds, sourceVariants: previousSnapshot.sourceVariants ?? currentSnapshot.sourceVariants, }, - previous: existingSnapshot, + previous: previousSnapshot, }), sourceVariant, ) @@ -285,7 +281,7 @@ class AssetSkuSyncService { const nextSnapshot = shouldUpdateTopLevel ? applyLockedSnapshotFields({ next: unlockedNextSnapshot, - previous: existingSnapshot, + previous: previousSnapshot, }) : unlockedNextSnapshot @@ -303,6 +299,12 @@ class AssetSkuSyncService { data: { attributes: nextAttributes as unknown as Prisma.InputJsonValue, images: nextImages, + assetProfile: { + upsert: { + create: buildProductSkuAssetProfileData(nextSnapshot), + update: buildProductSkuAssetProfileData(nextSnapshot), + }, + }, }, }) await safeDeleteCacheKeys([ diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts index 327c604f..bcf85143 100644 --- a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts @@ -65,6 +65,17 @@ function trimToNull(value: string | null | undefined): string | null { return normalized ? normalized : null } +function normalizeRequiredText(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${label}`) + } + const normalized = value.trim() + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${label}`) + } + return normalized +} + function normalizePrompt(value: unknown): string { if (typeof value !== 'string') { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请填写效果图提示词') @@ -149,11 +160,18 @@ function decodeReferenceImageDataUrl(value: unknown): { buffer: Buffer; contentT } } -function ensureJsonValue(value: unknown, message: string): Prisma.InputJsonValue { +function ensureJsonObject(value: unknown, message: string): Prisma.InputJsonValue { if (value == null) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, message) } - return value as Prisma.InputJsonValue + if (typeof value !== 'object' || Array.isArray(value)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${message}格式不正确`) + } + try { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue + } catch { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${message}必须是可保存的 JSON 对象`) + } } function normalizeResultImages(value: unknown): AssistedAssemblyAiRenderImageDTO[] { @@ -280,9 +298,10 @@ async function getExternalImageApiConfig(): Promise { export class AssistedAssemblyAiRenderService { async listForUser(user: AuthUser, options?: { limit?: number }): Promise { + const createdBy = normalizeRequiredText(user.id, '用户 ID') const limit = Math.min(Math.max(options?.limit ?? 24, 1), 60) const records = await prisma.assistedAssemblyAiRender.findMany({ - where: { createdBy: user.id }, + where: { createdBy }, orderBy: { createdAt: 'desc' }, take: limit, }) @@ -291,10 +310,12 @@ export class AssistedAssemblyAiRenderService { } async getForUser(id: string, user: AuthUser): Promise { + const normalizedId = normalizeRequiredText(id, 'AI 生图记录 ID') + const createdBy = normalizeRequiredText(user.id, '用户 ID') const record = await prisma.assistedAssemblyAiRender.findFirst({ where: { - id, - createdBy: user.id, + id: normalizedId, + createdBy, }, }) @@ -306,8 +327,10 @@ export class AssistedAssemblyAiRenderService { } async deleteForUser(id: string, user: AuthUser): Promise { + const normalizedId = normalizeRequiredText(id, 'AI 生图记录 ID') + const createdBy = normalizeRequiredText(user.id, '用户 ID') const record = await prisma.assistedAssemblyAiRender.findFirst({ - where: { id, createdBy: user.id }, + where: { id: normalizedId, createdBy }, select: { id: true }, }) if (!record) { @@ -318,6 +341,7 @@ export class AssistedAssemblyAiRenderService { async create(input: CreateAssistedAssemblyAiRenderInput, user: AuthUser): Promise { const startedAt = Date.now() + const createdBy = normalizeRequiredText(user.id, '用户 ID') const prompt = normalizePrompt(input.prompt) const negativePrompt = normalizeNegativePrompt(input.negativePrompt) const imageCount = normalizeImageCount(input.imageCount) @@ -330,18 +354,18 @@ export class AssistedAssemblyAiRenderService { imageSize, } - const storageKey = `assisted-assembly/ai-renders/${user.id}/${Date.now()}-${nanoid(8)}.${referenceImage.extension}` + const storageKey = `assisted-assembly/ai-renders/${createdBy}/${Date.now()}-${nanoid(8)}.${referenceImage.extension}` const referenceImageUrl = await uploadBufferToS3(referenceImage.buffer, storageKey, referenceImage.contentType) const baseCreateData = { - createdBy: user.id, prompt, negativePrompt, params: params as Prisma.InputJsonValue, referenceImageUrl, resultImages: [] as unknown as Prisma.InputJsonValue, - workspaceSnapshot: ensureJsonValue(input.workspaceSnapshot, '缺少辅助装配工作台快照'), - assetSnapshot: ensureJsonValue(input.assetSnapshot, '缺少辅助装配物料快照'), + createdBy, + workspaceSnapshot: ensureJsonObject(input.workspaceSnapshot, '缺少辅助装配工作台快照'), + assetSnapshot: ensureJsonObject(input.assetSnapshot, '缺少辅助装配物料快照'), status: AssistedAssemblyAiRenderStatus.PENDING, } diff --git a/furniture-3d-system/src/server/services/common-component.service.ts b/furniture-3d-system/src/server/services/common-component.service.ts index fb0a556c..a8916e2b 100644 --- a/furniture-3d-system/src/server/services/common-component.service.ts +++ b/furniture-3d-system/src/server/services/common-component.service.ts @@ -25,6 +25,18 @@ export type { UpdateCommonComponentInput, } from './common-component.service.types' +function normalizeCommonComponentCode(value: string): string { + return value.trim().toUpperCase() +} + +function normalizeRequiredText(value: string | null | undefined, fieldName: string): string { + const next = value?.trim() ?? '' + if (!next) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldName}不能为空`) + } + return next +} + class CommonComponentService { private readonly defaultCategories = [ { name: '抽屉组件', description: '可复用的抽屉整体模块', icon: '🗄️' }, @@ -107,7 +119,10 @@ class CommonComponentService { } async create(input: CreateCommonComponentInput): Promise { - const code = input.code.trim() + const code = normalizeCommonComponentCode(input.code) + if (!code) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '通用组件编码不能为空') + } const codeExists = await commonComponentRepository.codeExists(code) if (codeExists) { throw new AppError(ErrorCode.COMPONENT_CODE_EXISTS, '通用组件编码已存在') @@ -133,7 +148,7 @@ class CommonComponentService { } const component = await commonComponentRepository.createWithCategory({ - name: input.name.trim(), + name: normalizeRequiredText(input.name, '通用组件名称'), code, categoryId: input.categoryId, description: input.description?.trim() || null, @@ -164,7 +179,7 @@ class CommonComponentService { } const component = await commonComponentRepository.updateWithCategory(id, { - name: input.name?.trim(), + name: input.name === undefined ? undefined : normalizeRequiredText(input.name, '通用组件名称'), categoryId: input.categoryId, description: input.description === undefined ? undefined : input.description?.trim() || null, @@ -206,7 +221,7 @@ class CommonComponentService { } async createCategory(input: { name: string; description?: string; icon?: string }): Promise { - const name = input.name.trim() + const name = normalizeRequiredText(input.name, '分类名称') const nameExists = await commonComponentCategoryRepository.nameExists(name) if (nameExists) { throw new AppError(ErrorCode.CATEGORY_NAME_EXISTS, '分类名称已存在') diff --git a/furniture-3d-system/src/server/services/draft-group.mapper.ts b/furniture-3d-system/src/server/services/draft-group.mapper.ts index 06b433eb..b4a2cb3e 100644 --- a/furniture-3d-system/src/server/services/draft-group.mapper.ts +++ b/furniture-3d-system/src/server/services/draft-group.mapper.ts @@ -9,6 +9,7 @@ import type { import type { DraftGroupDTO, DraftGroupPreSkuDTO, + PreSkuBasicInfoDTO, PreSkuPackagingCostSummaryDTO, PreSkuPackagingInputDTO, PreSkuPackagingPlanBoxDTO, @@ -27,6 +28,63 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function readNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +const PRE_SKU_BASIC_INFO_DISPLAY_KEYS = new Set([ + 'model', + 'formalSkuCode', + 'skuCode', + 'draftSkuCode', + 'skuSequenceCode', + 'barcode', + 'size', + 'color', + 'material', + 'specName', + 'versionNo', + 'setCount', + 'attributeCode', + 'drawingId', + 'installMethod', +]) + +function toPreSkuBasicInfoDTO(record: PreSkuWithRelations): PreSkuBasicInfoDTO | null { + if (!record.basicInfo) return null + + return { + model: readNonEmptyString(record.basicInfo.model) ?? '', + formalSkuCode: readNonEmptyString(record.basicInfo.formalSkuCode) ?? '', + barcode: readNonEmptyString(record.basicInfo.barcode) ?? '', + size: readNonEmptyString(record.basicInfo.size) ?? '', + color: readNonEmptyString(record.basicInfo.color) ?? '', + material: readNonEmptyString(record.basicInfo.material) ?? '', + specName: readNonEmptyString(record.basicInfo.specName) ?? '', + versionNo: readNonEmptyString(record.basicInfo.versionNo) ?? '', + setCount: readNonEmptyString(record.basicInfo.setCount) ?? '', + attributeCode: readNonEmptyString(record.basicInfo.attributeCode) ?? '', + drawingId: readNonEmptyString(record.basicInfo.drawingId) ?? '', + installMethod: readNonEmptyString(record.basicInfo.installMethod) ?? '', + manualUpdatedAt: record.basicInfo.manualUpdatedAt?.toISOString() ?? null, + manualUpdatedBy: record.basicInfo.manualUpdatedBy ?? null, + manualUpdatedByName: record.basicInfo.manualUpdatedByName ?? null, + } +} + +function sanitizeDisplaySummaryForDTO( + displaySummary: Record | null, + basicInfo: PreSkuBasicInfoDTO | null, +) { + if (!displaySummary || !basicInfo) return displaySummary + + const sanitized = { ...displaySummary } + PRE_SKU_BASIC_INFO_DISPLAY_KEYS.forEach((key) => { + delete sanitized[key] + }) + return sanitized +} + function asArray(value: unknown): T[] { return Array.isArray(value) ? (value as T[]) : [] } @@ -589,7 +647,9 @@ export function buildPackagingInput(record: PreSkuWithRelations): PreSkuPackagin } export function toDraftGroupPreSkuDTO(record: PreSkuWithRelations): DraftGroupPreSkuDTO { - const displaySummary = isRecord(record.displaySummary) ? record.displaySummary : null + const rawDisplaySummary = isRecord(record.displaySummary) ? record.displaySummary : null + const basicInfo = toPreSkuBasicInfoDTO(record) + const displaySummary = sanitizeDisplaySummaryForDTO(rawDisplaySummary, basicInfo) const summaryWarnings = Array.isArray(displaySummary?.warnings) ? displaySummary.warnings.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) : [] @@ -617,6 +677,7 @@ export function toDraftGroupPreSkuDTO(record: PreSkuWithRelations): DraftGroupPr ? (record.recommendedHardware as unknown[]) : null, previewNodes: Array.isArray(record.previewNodes) ? (record.previewNodes as unknown[]) : [], + basicInfo, displaySummary, candidateSnapshot, assemblyWorkspace, diff --git a/furniture-3d-system/src/server/services/draft-group.service.ts b/furniture-3d-system/src/server/services/draft-group.service.ts index 995bc9a1..aaa3f3dc 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.ts @@ -1,4 +1,5 @@ import { Prisma } from '@prisma/client' +import { nanoid } from 'nanoid' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' import { deriveAssemblySaveValidation } from '@/lib/open-product/assembly-workspace' @@ -43,7 +44,8 @@ import type { } from './draft-group.service.types' function buildDraftGroupCode(): string { - return `DG-${Date.now()}` + const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) + return `DG-${timestamp}-${nanoid(6).toUpperCase()}` } function isRecord(value: unknown): value is Record { @@ -101,28 +103,88 @@ const CONFLICT_CHECK_FIELDS: PreSkuBasicInfoFieldKey[] = [ 'versionNo', ] +const BLOCKING_BASIC_INFO_DUPLICATE_FIELDS = new Set([ + 'formalSkuCode', + 'barcode', +]) + +const PRE_SKU_BASIC_INFO_DISPLAY_KEYS = new Set([ + ...Array.from(PRE_SKU_BASIC_INFO_FIELD_KEYS), + 'skuCode', + 'draftSkuCode', + 'skuSequenceCode', + 'basicInfoManualUpdatedAt', + 'basicInfoManualUpdatedBy', + 'basicInfoManualUpdatedByName', +]) + +const UPPERCASE_BASIC_INFO_FIELDS = new Set([ + 'model', + 'formalSkuCode', + 'barcode', + 'versionNo', + 'attributeCode', + 'drawingId', +]) + function readText(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } +function normalizePreSkuBasicInfoValue( + field: PreSkuBasicInfoFieldKey, + value: string | null | undefined, +): string { + const normalized = value?.trim() ?? '' + if (!normalized) return '' + return UPPERCASE_BASIC_INFO_FIELDS.has(field) ? normalized.toUpperCase() : normalized +} + +function readPreSkuBasicInfoValue( + preSku: PreSkuWithRelations, + field: PreSkuBasicInfoFieldKey, +): string { + return normalizePreSkuBasicInfoValue(field, readText(preSku.basicInfo?.[field])) +} + +function stripPreSkuBasicInfoDisplayKeys( + displaySummary: Record, +): Record { + const sanitized = { ...displaySummary } + PRE_SKU_BASIC_INFO_DISPLAY_KEYS.forEach((key) => { + delete sanitized[key] + }) + return sanitized +} + +function buildStructuredPreSkuBasicInfoValues( + values: Partial>, +): Record { + return Array.from(PRE_SKU_BASIC_INFO_FIELD_KEYS).reduce( + (result, field) => { + const key = field as PreSkuBasicInfoFieldKey + result[key] = normalizePreSkuBasicInfoValue(key, values[key]) || null + return result + }, + {} as Record, + ) +} + +function flattenConflicts(conflicts: PreSkuBasicInfoMetadataDTO['conflicts']) { + return Object.values(conflicts).flat() +} + function setIfPresent( target: Partial>, key: PreSkuBasicInfoFieldKey, value: string | null | undefined, ) { - const normalized = value?.trim() + const normalized = normalizePreSkuBasicInfoValue(key, value) if (normalized) { target[key] = normalized } } -function readBasisText( - item: { assemblyBasis: Record | null } | null | undefined, - key: string, -): string { - return isRecord(item?.assemblyBasis) ? readText(item.assemblyBasis[key]) : '' -} - function addUniqueOption( target: PreSkuBasicInfoOptionDTO[], value: string | null | undefined, @@ -372,17 +434,15 @@ class DraftGroupService { } const suggestions = await this.buildPreSkuBasicInfoSuggestions(preSku) - const displaySummary = isRecord(preSku.displaySummary) - ? preSku.displaySummary - : {} const values: Partial> = {} PRE_SKU_AUTO_FILL_FIELDS.forEach((field) => { const submittedValue = fields && field in fields ? fields[field] : undefined - const normalizedSubmittedValue = typeof submittedValue === 'string' - ? submittedValue.trim() - : '' - const summaryValue = readText(displaySummary[field]) + const normalizedSubmittedValue = normalizePreSkuBasicInfoValue( + field, + typeof submittedValue === 'string' ? submittedValue : '', + ) + const summaryValue = readPreSkuBasicInfoValue(preSku, field) values[field] = normalizedSubmittedValue || summaryValue || suggestions[field] || '' }) @@ -508,38 +568,71 @@ class DraftGroupService { if (!preSku) { throw new AppError(ErrorCode.PRE_SKU_NOT_FOUND, '准 SKU 不存在') } + if (preSku.status === 'FORMALIZED' || preSku.status === 'ARCHIVED') { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + '已正式入库或已归档的准 SKU 不能继续修改基础资料', + ) + } const suggestions = await this.buildPreSkuBasicInfoSuggestions(preSku) - const displaySummary = isRecord(preSku.displaySummary) - ? { ...preSku.displaySummary } - : {} + const displaySummary = stripPreSkuBasicInfoDisplayKeys( + isRecord(preSku.displaySummary) ? preSku.displaySummary : {}, + ) + const values: Partial> = {} PRE_SKU_AUTO_FILL_FIELDS.forEach((field) => { - if (readText(displaySummary[field])) return + const existing = readPreSkuBasicInfoValue(preSku, field) + if (existing) { + values[field] = existing + return + } const suggestion = suggestions[field] if (suggestion) { - displaySummary[field] = suggestion + values[field] = suggestion } }) Object.entries(input.fields).forEach(([key, value]) => { if (!PRE_SKU_BASIC_INFO_FIELD_KEYS.has(key)) return - const normalized = typeof value === 'string' ? value.trim() : '' + const field = key as PreSkuBasicInfoFieldKey + const normalized = normalizePreSkuBasicInfoValue( + field, + typeof value === 'string' ? value : '', + ) if (normalized) { - displaySummary[key] = normalized + values[field] = normalized } else { - delete displaySummary[key] + values[field] = '' } }) - displaySummary.basicInfoManualUpdatedAt = new Date().toISOString() - displaySummary.basicInfoManualUpdatedBy = user.id - displaySummary.basicInfoManualUpdatedByName = user.name + PRE_SKU_BASIC_INFO_FIELD_KEYS.forEach((field) => { + const key = field as PreSkuBasicInfoFieldKey + if (key in values) return + const existing = readPreSkuBasicInfoValue(preSku, key) + if (existing) { + values[key] = existing + } + }) - const updated = await preSkuRepository.updateDisplaySummary( - preSkuId, - displaySummary as Prisma.InputJsonValue, - ) + const conflicts = await this.detectPreSkuBasicInfoConflicts(preSku, values) + const blockingConflicts = flattenConflicts(conflicts).filter((conflict) => conflict.level === 'error') + if (blockingConflicts.length > 0) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + blockingConflicts[0]?.message ?? '基础资料存在冲突,不能保存', + ) + } + + await preSkuRepository.updateDisplaySummary(preSkuId, displaySummary as Prisma.InputJsonValue) + const updated = await preSkuRepository.upsertBasicInfo(preSkuId, { + ...buildStructuredPreSkuBasicInfoValues(values), + manualUpdatedAt: new Date(), + manualUpdatedBy: user.id, + manualUpdatedByName: user.name, + source: 'MANUAL', + }) return toDraftGroupPreSkuDTO(updated) } @@ -569,21 +662,6 @@ class DraftGroupService { const primaryVariant = variants.find((variant) => variant.id === snapshot?.sourceAssetVariantId) setIfPresent(suggestions, 'versionNo', primaryVariant?.versionNo ?? DEFAULT_SKU_VERSION_NO) - if (!suggestions.model || !suggestions.formalSkuCode || !suggestions.barcode || !suggestions.versionNo) { - const selection = normalizeVariantSelection(preSku.variantSelection) - const allSelections = [...selection.slots, ...selection.hardwareSelections] - const primarySelection = - selection.slots.find((slot) => slot.variantId === primaryVariantId) ?? - allSelections.find((item) => item.variantId === primaryVariantId) ?? - allSelections[0] - - setIfPresent(suggestions, 'model', readBasisText(primarySelection, 'assetFamilyCode')) - setIfPresent(suggestions, 'formalSkuCode', primarySelection?.code) - setIfPresent(suggestions, 'barcode', readBasisText(primarySelection, 'barcode')) - setIfPresent(suggestions, 'versionNo', readBasisText(primarySelection, 'versionNo') || DEFAULT_SKU_VERSION_NO) - setIfPresent(suggestions, 'specName', primarySelection?.name) - } - setIfPresent(suggestions, 'versionNo', suggestions.versionNo ?? DEFAULT_SKU_VERSION_NO) return suggestions @@ -626,29 +704,32 @@ class DraftGroupService { await Promise.all( CONFLICT_CHECK_FIELDS.map(async (field) => { - const value = values[field]?.trim() + const value = normalizePreSkuBasicInfoValue(field, values[field]) if (!value) return - const sameFieldPreSkus = await preSkuRepository.findByDisplaySummaryField( + const sameFieldPreSkus = await preSkuRepository.findByBasicInfoField( field, value, preSku.id, ) sameFieldPreSkus.forEach((record) => { + const isBlocking = BLOCKING_BASIC_INFO_DUPLICATE_FIELDS.has(field) addConflict(conflicts, { field, - level: 'warning', + level: isBlocking ? 'error' : 'warning', source: '草稿组', - message: `已被其他准 SKU 使用:${record.customName || record.defaultName}`, + message: isBlocking + ? `已被其他准 SKU 使用,不能保存:${record.customName || record.defaultName}` + : `已被其他准 SKU 使用:${record.customName || record.defaultName}`, }) }) }), ) - const specName = values.specName?.trim() - const versionNo = values.versionNo?.trim() + const specName = normalizePreSkuBasicInfoValue('specName', values.specName) + const versionNo = normalizePreSkuBasicInfoValue('versionNo', values.versionNo) if (specName && versionNo) { - const sameSpecVersionPreSkus = await preSkuRepository.findByDisplaySummaryFields( + const sameSpecVersionPreSkus = await preSkuRepository.findByBasicInfoFields( { specName, versionNo }, preSku.id, ) @@ -668,7 +749,7 @@ class DraftGroupService { }) } - const model = values.model?.trim() + const model = normalizePreSkuBasicInfoValue('model', values.model) if (model) { const [product, assetFamily] = await Promise.all([ productRepository.findByModelNumber(model), @@ -692,7 +773,7 @@ class DraftGroupService { } } - const formalSkuCode = values.formalSkuCode?.trim() + const formalSkuCode = normalizePreSkuBasicInfoValue('formalSkuCode', values.formalSkuCode) if (formalSkuCode) { const [assetVariant, sku] = await Promise.all([ assetVariantRepository.findByCode(formalSkuCode), @@ -701,7 +782,7 @@ class DraftGroupService { if (assetVariant && !resolveSelectedVariantIds(preSku).includes(assetVariant.id)) { addConflict(conflicts, { field: 'formalSkuCode', - level: 'warning', + level: 'error', source: '资产中心', message: `规格款编码已被其他资产使用:${assetVariant.name}`, }) @@ -716,11 +797,11 @@ class DraftGroupService { } } - const barcode = values.barcode?.trim() + const barcode = normalizePreSkuBasicInfoValue('barcode', values.barcode) if (barcode) { const [assetVariant, sku] = await Promise.all([ assetVariantRepository.findByBarcode(barcode), - skuRepository.findByAssetSnapshotBarcode(barcode), + skuRepository.findByAssetProfileBarcode(barcode), ]) if (assetVariant && !resolveSelectedVariantIds(preSku).includes(assetVariant.id)) { addConflict(conflicts, { diff --git a/furniture-3d-system/src/server/services/draft-group.service.types.ts b/furniture-3d-system/src/server/services/draft-group.service.types.ts index adc2d9b4..e802e8f5 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.types.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.types.ts @@ -68,6 +68,24 @@ export interface DraftGroupDTO { updatedAt: Date } +export interface PreSkuBasicInfoDTO { + model: string + formalSkuCode: string + barcode: string + size: string + color: string + material: string + specName: string + versionNo: string + setCount: string + attributeCode: string + drawingId: string + installMethod: string + manualUpdatedAt: string | null + manualUpdatedBy: string | null + manualUpdatedByName: string | null +} + export interface DraftGroupPreSkuDTO { id: string draftGroupId: string @@ -80,6 +98,7 @@ export interface DraftGroupPreSkuDTO { variantSelection: PreSkuVariantSelectionDTO recommendedHardware: unknown[] | null previewNodes: unknown[] + basicInfo: PreSkuBasicInfoDTO | null displaySummary: Record | null candidateSnapshot: CombinationCandidateSnapshot | null assemblyWorkspace: AssemblyWorkspaceSnapshot | null diff --git a/furniture-3d-system/src/server/services/employee-directory-sync.service.ts b/furniture-3d-system/src/server/services/employee-directory-sync.service.ts index 811e4834..3deb8871 100644 --- a/furniture-3d-system/src/server/services/employee-directory-sync.service.ts +++ b/furniture-3d-system/src/server/services/employee-directory-sync.service.ts @@ -39,28 +39,75 @@ export interface HrSyncPayload { dimissions: HrSyncDimissionInput[] } +function normalizeRequiredText(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + if (value === undefined || value === null) return null + const normalized = value.trim() + return normalized || null +} + +function normalizeOptionalEmail(value: string | null | undefined): string | null { + const normalized = normalizeOptionalText(value) + return normalized ? normalized.toLowerCase() : null +} + +function normalizeDepartmentExternalIds(value: Prisma.InputJsonValue | undefined): Prisma.InputJsonValue | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) return value + return Array.from(new Set(value.map((item) => normalizeRequiredText(item)).filter(Boolean))) +} + class EmployeeDirectorySyncService { async sync(payload: HrSyncPayload) { if (!Array.isArray(payload.departments) || !Array.isArray(payload.employees) || !Array.isArray(payload.dimissions)) { throw new AppError(ErrorCode.VALIDATION_FAILED, '同步数据格式不正确') } - for (const department of payload.departments) { + const departments = payload.departments.map((department) => ({ + ...department, + externalId: normalizeRequiredText(department.externalId), + name: normalizeRequiredText(department.name), + parentExternalId: normalizeOptionalText(department.parentExternalId), + })) + const employees = payload.employees.map((employee) => ({ + ...employee, + dingtalkUserId: normalizeOptionalText(employee.dingtalkUserId), + dingtalkUnionId: normalizeOptionalText(employee.dingtalkUnionId), + jobNumber: normalizeOptionalText(employee.jobNumber), + name: normalizeRequiredText(employee.name), + mobile: normalizeOptionalText(employee.mobile), + email: normalizeOptionalEmail(employee.email), + title: normalizeOptionalText(employee.title), + primaryDepartmentExternalId: normalizeOptionalText(employee.primaryDepartmentExternalId), + departmentExternalIds: normalizeDepartmentExternalIds(employee.departmentExternalIds), + })) + const dimissions = payload.dimissions.map((dimission) => ({ + ...dimission, + dingtalkUserId: normalizeOptionalText(dimission.dingtalkUserId), + dingtalkUnionId: normalizeOptionalText(dimission.dingtalkUnionId), + mobile: normalizeOptionalText(dimission.mobile), + jobNumber: normalizeOptionalText(dimission.jobNumber), + })) + + for (const department of departments) { if (!department.externalId || !department.name) { throw new AppError(ErrorCode.VALIDATION_FAILED, '部门同步数据缺少必要字段') } } - for (const employee of payload.employees) { + for (const employee of employees) { if (!employee.name) { throw new AppError(ErrorCode.VALIDATION_FAILED, '员工同步数据缺少姓名') } } return employeeDirectoryRepository.upsertMany({ - departments: payload.departments, - employees: payload.employees, - dimissionEntries: payload.dimissions, + departments, + employees, + dimissionEntries: dimissions, }) } } diff --git a/furniture-3d-system/src/server/services/feedback-workflow.helper.ts b/furniture-3d-system/src/server/services/feedback-workflow.helper.ts index d581df52..a7d63472 100644 --- a/furniture-3d-system/src/server/services/feedback-workflow.helper.ts +++ b/furniture-3d-system/src/server/services/feedback-workflow.helper.ts @@ -25,27 +25,48 @@ const feedbackInclude = { }, } as const +function normalizeRequiredText(value: unknown, message: string): string { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, message) + } + const normalized = value.trim() + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, message) + } + return normalized +} + +function normalizeActor(actor: FeedbackWorkflowActor): FeedbackWorkflowActor { + return { + id: normalizeRequiredText(actor.id, '操作人不能为空'), + name: normalizeRequiredText(actor.name, '操作人名称不能为空'), + } +} + export async function addFeedbackComment( feedbackId: string, actor: FeedbackWorkflowActor, content: string ): Promise { - const feedback = await feedbackRepository.findById(feedbackId) + const normalizedFeedbackId = normalizeRequiredText(feedbackId, '反馈 ID 不能为空') + const normalizedActor = normalizeActor(actor) + const normalizedContent = normalizeRequiredText(content, '评论内容不能为空') + const feedback = await feedbackRepository.findById(normalizedFeedbackId) if (!feedback) { throw new AppError(ErrorCode.FILE_NOT_FOUND, '反馈不存在') } const comments = (feedback.comments as Array>) || [] comments.push({ - userId: actor.id, - userName: actor.name, - content, + userId: normalizedActor.id, + userName: normalizedActor.name, + content: normalizedContent, createdAt: new Date().toISOString(), }) const updated = await prisma.$transaction(async (tx) => { const result = await tx.feedback.update({ - where: { id: feedbackId }, + where: { id: normalizedFeedbackId }, data: { status: 'IN_PROGRESS', comments: comments as unknown as object, @@ -55,12 +76,12 @@ export async function addFeedbackComment( await tx.activityLog.create({ data: { - userId: actor.id, + userId: normalizedActor.id, type: 'FEEDBACK_UPDATE', targetType: 'Feedback', - targetId: feedbackId, + targetId: normalizedFeedbackId, action: '添加评论', - metadata: { action: 'comment', content }, + metadata: { action: 'comment', content: normalizedContent }, }, }) @@ -74,19 +95,21 @@ export async function closeFeedback( feedbackId: string, actor: FeedbackWorkflowActor ): Promise { + const normalizedFeedbackId = normalizeRequiredText(feedbackId, '反馈 ID 不能为空') + const normalizedActor = normalizeActor(actor) const updated = await prisma.$transaction(async (tx) => { const result = await tx.feedback.update({ - where: { id: feedbackId }, + where: { id: normalizedFeedbackId }, data: { status: 'CLOSED' }, include: feedbackInclude, }) await tx.activityLog.create({ data: { - userId: actor.id, + userId: normalizedActor.id, type: 'FEEDBACK_UPDATE', targetType: 'Feedback', - targetId: feedbackId, + targetId: normalizedFeedbackId, action: '关闭反馈', metadata: { action: 'close' }, }, @@ -103,8 +126,11 @@ export async function reassignFeedback( assigneeId: string, actor: FeedbackWorkflowActor ): Promise { + const normalizedFeedbackId = normalizeRequiredText(feedbackId, '反馈 ID 不能为空') + const normalizedAssigneeId = normalizeRequiredText(assigneeId, '新处理人不能为空') + const normalizedActor = normalizeActor(actor) const assignee = await prisma.user.findUnique({ - where: { id: assigneeId }, + where: { id: normalizedAssigneeId }, select: { id: true }, }) @@ -114,19 +140,19 @@ export async function reassignFeedback( const updated = await prisma.$transaction(async (tx) => { const result = await tx.feedback.update({ - where: { id: feedbackId }, - data: { assigneeId }, + where: { id: normalizedFeedbackId }, + data: { assigneeId: normalizedAssigneeId }, include: feedbackInclude, }) await tx.activityLog.create({ data: { - userId: actor.id, + userId: normalizedActor.id, type: 'FEEDBACK_UPDATE', targetType: 'Feedback', - targetId: feedbackId, + targetId: normalizedFeedbackId, action: '重新分配', - metadata: { action: 'reassign', assigneeId }, + metadata: { action: 'reassign', assigneeId: normalizedAssigneeId }, }, }) @@ -140,9 +166,11 @@ export async function reopenFeedback( feedbackId: string, actor: FeedbackWorkflowActor ): Promise { + const normalizedFeedbackId = normalizeRequiredText(feedbackId, '反馈 ID 不能为空') + const normalizedActor = normalizeActor(actor) const updated = await prisma.$transaction(async (tx) => { const result = await tx.feedback.update({ - where: { id: feedbackId }, + where: { id: normalizedFeedbackId }, data: { status: 'PENDING', resolution: null, @@ -153,10 +181,10 @@ export async function reopenFeedback( await tx.activityLog.create({ data: { - userId: actor.id, + userId: normalizedActor.id, type: 'FEEDBACK_UPDATE', targetType: 'Feedback', - targetId: feedbackId, + targetId: normalizedFeedbackId, action: '重新打开反馈', metadata: { action: 'reopen' }, }, diff --git a/furniture-3d-system/src/server/services/feedback-workflow.service.ts b/furniture-3d-system/src/server/services/feedback-workflow.service.ts index c235b548..f5c0a564 100644 --- a/furniture-3d-system/src/server/services/feedback-workflow.service.ts +++ b/furniture-3d-system/src/server/services/feedback-workflow.service.ts @@ -18,25 +18,67 @@ import type { type CheckHandlePermission = (user: FeedbackUserContext, feedback: Feedback) => Promise +function normalizeRequiredText(value: unknown, message: string): string { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, message) + } + const normalized = value.trim() + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, message) + } + return normalized +} + +function normalizeOptionalId(value: unknown): string | null { + if (value == null) return null + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'SKU ID 格式不正确') + } + const normalized = value.trim() + return normalized ? normalized : null +} + +function normalizeImages(value: unknown): string[] { + if (value == null) return [] + if (!Array.isArray(value)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '反馈图片格式不正确') + } + return Array.from( + new Set( + value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + ), + ) +} + class FeedbackWorkflowService { constructor(private readonly checkHandlePermission: CheckHandlePermission) {} async create(input: CreateFeedbackInput, user: FeedbackUserContext): Promise { + const productId = normalizeRequiredText(input.productId, '产品不能为空') + const skuId = normalizeOptionalId(input.skuId) + const title = normalizeRequiredText(input.title, '反馈标题不能为空') + const description = normalizeRequiredText(input.description, '反馈描述不能为空') + const category = normalizeRequiredText(input.category, '反馈分类不能为空') + const submitterId = normalizeRequiredText(user.id, '提交人不能为空') + const images = normalizeImages(input.images) const product = await prisma.product.findUnique({ - where: { id: input.productId, deletedAt: null }, + where: { id: productId, deletedAt: null }, select: { id: true }, }) if (!product) { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '产品不存在') } - if (input.skuId) { + if (skuId) { const sku = await prisma.productSKU.findUnique({ - where: { id: input.skuId, deletedAt: null }, + where: { id: skuId, deletedAt: null }, select: { id: true, productId: true }, }) - if (!sku || sku.productId !== input.productId) { + if (!sku || sku.productId !== productId) { throw new AppError(ErrorCode.SKU_NOT_FOUND, 'SKU 不存在或不属于该产品') } } @@ -55,14 +97,14 @@ class FeedbackWorkflowService { return tx.feedback.create({ data: { - productId: input.productId, - skuId: input.skuId || null, - title: input.title, - description: input.description, - images: input.images || [], - category: input.category, + productId, + skuId, + title, + description, + images, + category, priority: input.priority || 'MEDIUM', - submitterId: user.id, + submitterId, assigneeId: assignee?.id || null, }, include: { @@ -89,6 +131,7 @@ class FeedbackWorkflowService { } async markAsResolved(id: string, resolution: string, user: FeedbackUserContext): Promise { + const normalizedResolution = normalizeRequiredText(resolution, '解决方案不能为空') const existing = await feedbackRepository.findByIdWithRelations(id) if (!existing) { throw new AppError(ErrorCode.FILE_NOT_FOUND, 'Feedback not found') @@ -104,7 +147,7 @@ class FeedbackWorkflowService { where: { id }, data: { status: 'RESOLVED', - resolution, + resolution: normalizedResolution, resolvedAt: new Date(), }, }) @@ -118,7 +161,7 @@ class FeedbackWorkflowService { action: '解决反馈', metadata: { action: 'resolve', - resolution, + resolution: normalizedResolution, }, }, }) @@ -146,30 +189,25 @@ class FeedbackWorkflowService { switch (input.action) { case 'comment': { - if (!input.content) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '评论内容不能为空') - } + const content = normalizeRequiredText(input.content, '评论内容不能为空') const updated = await addFeedbackComment( id, { id: user.id, name: user.name }, - input.content + content ) return toFeedbackDTO(updated) } - case 'resolve': - if (!input.resolution) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '解决方案不能为空') - } - return this.markAsResolved(id, input.resolution, user) + case 'resolve': { + const resolution = normalizeRequiredText(input.resolution, '解决方案不能为空') + return this.markAsResolved(id, resolution, user) + } case 'close': { const updated = await closeFeedback(id, { id: user.id, name: user.name }) return toFeedbackDTO(updated) } case 'reassign': { - if (!input.assigneeId) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '新处理人不能为空') - } - const updated = await reassignFeedback(id, input.assigneeId, { + const assigneeId = normalizeRequiredText(input.assigneeId, '新处理人不能为空') + const updated = await reassignFeedback(id, assigneeId, { id: user.id, name: user.name, }) diff --git a/furniture-3d-system/src/server/services/hardware-matcher.runtime.ts b/furniture-3d-system/src/server/services/hardware-matcher.runtime.ts index 4706edda..b9de55a5 100644 --- a/furniture-3d-system/src/server/services/hardware-matcher.runtime.ts +++ b/furniture-3d-system/src/server/services/hardware-matcher.runtime.ts @@ -5,6 +5,10 @@ import { type HardwareMatcherDataSource, } from '@/lib/hardware-matcher' +function normalizeOptionalText(value: string | null | undefined): string | null { + return value?.trim() || null +} + const hardwareMatcherDataSource: HardwareMatcherDataSource = { async loadHardwareLibrary() { const hardwares = await prisma.hardware.findMany({ @@ -55,12 +59,14 @@ const hardwareMatcherDataSource: HardwareMatcherDataSource = { }) }, async saveMapping(input) { + const blockName = normalizeOptionalText(input.blockName) + const signatureHash = input.signatureHash.trim() await prisma.$transaction(async (tx) => { - if (input.blockName) { + if (blockName) { await tx.hardwareMapping.upsert({ - where: { blockName: input.blockName }, + where: { blockName }, create: { - blockName: input.blockName, + blockName, hardwareId: input.hardwareId, createdBy: 'manual', }, @@ -72,9 +78,9 @@ const hardwareMatcherDataSource: HardwareMatcherDataSource = { } await tx.hardwareMapping.upsert({ - where: { signatureHash: input.signatureHash }, + where: { signatureHash }, create: { - signatureHash: input.signatureHash, + signatureHash, hardwareId: input.hardwareId, createdBy: 'manual', }, diff --git a/furniture-3d-system/src/server/services/hardware-write-ops.ts b/furniture-3d-system/src/server/services/hardware-write-ops.ts index c0104984..a3df27f7 100644 --- a/furniture-3d-system/src/server/services/hardware-write-ops.ts +++ b/furniture-3d-system/src/server/services/hardware-write-ops.ts @@ -4,12 +4,29 @@ import path from 'path' import { Prisma } from '@prisma/client' import { AppError, ErrorCode } from '@/server/core/errors' import { calculateGLBGeometry } from '@/lib/glb-geometry' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { hardwareRepository } from '../repositories' import type { SyncHardwareInput, SyncHardwareResult } from './hardware.service.types' import { toNullableJsonInput } from './hardware-dto.mapper' type SyncStats = SyncHardwareResult['stats'] +function normalizeHardwareCode(value: string): string { + return normalizeCoreIdentity(value) +} + +function normalizeRequiredText(value: string | null | undefined, fieldName: string): string { + const next = value?.trim() ?? '' + if (!next) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldName}不能为空`) + } + return next +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + return value?.trim() || null +} + export async function uploadHardwareModelFile( buffer: Buffer, originalName: string, @@ -66,8 +83,9 @@ async function upsertMappingInTransaction( hardwareId: string, dimensions?: { width: number; height: number; depth: number } | null ): Promise { + const normalizedBlockName = normalizeRequiredText(blockName, '五金图块名') const existingBlockMapping = await tx.hardwareMapping.findUnique({ - where: { blockName }, + where: { blockName: normalizedBlockName }, }) if (existingBlockMapping) { @@ -81,7 +99,7 @@ async function upsertMappingInTransaction( } else { await tx.hardwareMapping.create({ data: { - blockName, + blockName: normalizedBlockName, hardwareId, createdBy: 'sync', usageCount: 1, @@ -116,14 +134,15 @@ async function ensureCategories( categoryNames: string[], stats: SyncStats ): Promise> { + const normalizedCategoryNames = [...new Set(categoryNames.map((item) => normalizeRequiredText(item, '五金分类')))] const existingCategories = await tx.hardwareCategory.findMany({ - where: { name: { in: categoryNames } }, + where: { name: { in: normalizedCategoryNames } }, select: { id: true, name: true }, }) const existingCategoryMap = new Map(existingCategories.map((category) => [category.name, category])) - for (const categoryName of categoryNames) { + for (const categoryName of normalizedCategoryNames) { if (!existingCategoryMap.has(categoryName)) { const newCategory = await tx.hardwareCategory.create({ data: { @@ -171,15 +190,25 @@ export async function syncHardwareData(input: SyncHardwareInput): Promise { - const code = input.code.trim() + const code = normalizeHardwareCode(input.code) + if (!code) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '五金件编码不能为空') + } const codeExists = await hardwareRepository.codeExists(code) if (codeExists) { throw new AppError(ErrorCode.VALIDATION_FAILED, '五金件编码已存在') @@ -138,7 +163,7 @@ class HardwareService { } const hardware = await hardwareRepository.createWithCategory({ - name: input.name.trim(), + name: normalizeRequiredText(input.name, '五金件名称'), code, categoryId: input.categoryId, specifications: toNullableJsonInput(input.specifications), @@ -177,7 +202,7 @@ class HardwareService { } const hardware = await hardwareRepository.updateWithCategory(id, { - name: input.name?.trim(), + name: input.name === undefined ? undefined : normalizeRequiredText(input.name, '五金件名称'), categoryId: input.categoryId, specifications: toNullableJsonInput(input.specifications), dimensions: toNullableJsonInput(input.dimensions), @@ -227,7 +252,7 @@ class HardwareService { description?: string icon?: string }): Promise { - const name = input.name.trim() + const name = normalizeRequiredText(input.name, '分类名称') const nameExists = await hardwareCategoryRepository.nameExists(name) if (nameExists) { throw new AppError(ErrorCode.VALIDATION_FAILED, '分类名称已存在') @@ -237,8 +262,8 @@ class HardwareService { const category = await hardwareCategoryRepository.create({ name, - description: input.description, - icon: input.icon, + description: normalizeOptionalText(input.description), + icon: normalizeOptionalText(input.icon), sortOrder: maxSortOrder + 1, isActive: true, }) @@ -293,7 +318,15 @@ class HardwareService { throw new AppError(ErrorCode.HARDWARE_NOT_FOUND, '五金件不存在') } - await saveHardwareMapping(input.blockName || null, input.signature, input.hardwareId) + const blockName = normalizeOptionalText(input.blockName) + assertPositiveFiniteNumber(input.signature.width, '五金尺寸宽度') + assertPositiveFiniteNumber(input.signature.height, '五金尺寸高度') + assertPositiveFiniteNumber(input.signature.depth, '五金尺寸深度') + if (input.signature.volume !== undefined && input.signature.volume !== null) { + assertPositiveFiniteNumber(input.signature.volume, '五金体积') + } + + await saveHardwareMapping(blockName, input.signature, input.hardwareId) return { message: '映射关系已保存', diff --git a/furniture-3d-system/src/server/services/hole-crud.service.ts b/furniture-3d-system/src/server/services/hole-crud.service.ts index 085fc955..1cbaf35d 100644 --- a/furniture-3d-system/src/server/services/hole-crud.service.ts +++ b/furniture-3d-system/src/server/services/hole-crud.service.ts @@ -45,6 +45,8 @@ type ProductPermissionSnapshot = NonNullable< Awaited> > +const HOLE_TYPES = new Set(['hinge', 'connector', 'shelf_pin', 'screw', 'dowel', 'cable', 'unknown']) + class HoleCrudService { private cache = cacheService @@ -193,8 +195,10 @@ class HoleCrudService { const product = await this.getProductForPermission(panel.productId) this.assertCanEditProduct(product, user, '您没有权限创建孔位') - if (input.hardwareId) { - const hardware = await hardwareRepository.findById(input.hardwareId) + const normalizedInput = this.normalizeCreateHoleInput(input) + + if (normalizedInput.hardwareId) { + const hardware = await hardwareRepository.findById(normalizedInput.hardwareId) if (!hardware) { throw new AppError(ErrorCode.HARDWARE_NOT_FOUND, '五金件不存在') } @@ -202,19 +206,19 @@ class HoleCrudService { const hole = await holeRepository.create({ panel: { connect: { id: panelId } }, - positionX: input.positionX, - positionY: input.positionY, - positionZ: input.positionZ, - diameter: input.diameter, - depth: input.depth, - normalX: input.normalX ?? 0, - normalY: input.normalY ?? 0, - normalZ: input.normalZ ?? 1, - holeType: input.holeType || null, - confidence: input.confidence ?? 1, - isManual: input.isManual ?? false, - groupId: input.groupId || null, - hardware: input.hardwareId ? { connect: { id: input.hardwareId } } : undefined, + positionX: normalizedInput.positionX, + positionY: normalizedInput.positionY, + positionZ: normalizedInput.positionZ, + diameter: normalizedInput.diameter, + depth: normalizedInput.depth, + normalX: normalizedInput.normalX ?? 0, + normalY: normalizedInput.normalY ?? 0, + normalZ: normalizedInput.normalZ ?? 1, + holeType: normalizedInput.holeType || null, + confidence: normalizedInput.confidence ?? 1, + isManual: normalizedInput.isManual ?? false, + groupId: normalizedInput.groupId || null, + hardware: normalizedInput.hardwareId ? { connect: { id: normalizedInput.hardwareId } } : undefined, }) await this.invalidatePanelCache(panelId) @@ -242,7 +246,7 @@ class HoleCrudService { const product = await this.getProductForPermission(panel.productId) this.assertCanEditProduct(product, user, '您没有权限批量创建孔位') - const data = holes.map((input) => ({ + const data = holes.map((input) => this.normalizeCreateHoleInput(input)).map((input) => ({ panelId, positionX: input.positionX, positionY: input.positionY, @@ -280,30 +284,32 @@ class HoleCrudService { const product = await this.getProductForPermission(existing.panel.productId) this.assertCanEditProduct(product, user, '您没有权限编辑该孔位') - if (input.hardwareId) { - const hardware = await hardwareRepository.findById(input.hardwareId) + const normalizedInput = this.normalizeUpdateHoleInput(input) + + if (normalizedInput.hardwareId) { + const hardware = await hardwareRepository.findById(normalizedInput.hardwareId) if (!hardware) { throw new AppError(ErrorCode.HARDWARE_NOT_FOUND, '五金件不存在') } } const updateData: Prisma.HoleUpdateInput = {} - if (input.positionX !== undefined) updateData.positionX = input.positionX - if (input.positionY !== undefined) updateData.positionY = input.positionY - if (input.positionZ !== undefined) updateData.positionZ = input.positionZ - if (input.diameter !== undefined) updateData.diameter = input.diameter - if (input.depth !== undefined) updateData.depth = input.depth - if (input.normalX !== undefined) updateData.normalX = input.normalX - if (input.normalY !== undefined) updateData.normalY = input.normalY - if (input.normalZ !== undefined) updateData.normalZ = input.normalZ - if (input.holeType !== undefined) updateData.holeType = input.holeType - if (input.confidence !== undefined) updateData.confidence = input.confidence - if (input.isManual !== undefined) updateData.isManual = input.isManual - if (input.groupId !== undefined) updateData.groupId = input.groupId + if (normalizedInput.positionX !== undefined) updateData.positionX = normalizedInput.positionX + if (normalizedInput.positionY !== undefined) updateData.positionY = normalizedInput.positionY + if (normalizedInput.positionZ !== undefined) updateData.positionZ = normalizedInput.positionZ + if (normalizedInput.diameter !== undefined) updateData.diameter = normalizedInput.diameter + if (normalizedInput.depth !== undefined) updateData.depth = normalizedInput.depth + if (normalizedInput.normalX !== undefined) updateData.normalX = normalizedInput.normalX + if (normalizedInput.normalY !== undefined) updateData.normalY = normalizedInput.normalY + if (normalizedInput.normalZ !== undefined) updateData.normalZ = normalizedInput.normalZ + if (input.holeType !== undefined) updateData.holeType = normalizedInput.holeType + if (normalizedInput.confidence !== undefined) updateData.confidence = normalizedInput.confidence + if (normalizedInput.isManual !== undefined) updateData.isManual = normalizedInput.isManual + if (input.groupId !== undefined) updateData.groupId = normalizedInput.groupId - if (input.hardwareId !== undefined) { - updateData.hardware = input.hardwareId - ? { connect: { id: input.hardwareId } } + if (normalizedInput.hardwareId !== undefined) { + updateData.hardware = normalizedInput.hardwareId + ? { connect: { id: normalizedInput.hardwareId } } : { disconnect: true } } @@ -538,6 +544,105 @@ class HoleCrudService { return ctx.user } + private normalizeCreateHoleInput(input: CreateHoleInput): CreateHoleInput { + const normalX = this.normalizeFiniteNumber(input.normalX ?? 0, '孔轴 X') + const normalY = this.normalizeFiniteNumber(input.normalY ?? 0, '孔轴 Y') + const normalZ = this.normalizeFiniteNumber(input.normalZ ?? 1, '孔轴 Z') + this.assertNonZeroNormal(normalX, normalY, normalZ) + + return { + ...input, + positionX: this.normalizeFiniteNumber(input.positionX, '孔位 X 坐标'), + positionY: this.normalizeFiniteNumber(input.positionY, '孔位 Y 坐标'), + positionZ: this.normalizeFiniteNumber(input.positionZ, '孔位 Z 坐标'), + diameter: this.normalizePositiveNumber(input.diameter, '孔径'), + depth: this.normalizeNonNegativeNumber(input.depth, '孔深'), + normalX, + normalY, + normalZ, + holeType: this.normalizeHoleType(input.holeType) ?? undefined, + confidence: + input.confidence === undefined + ? undefined + : this.normalizeConfidence(input.confidence), + groupId: this.normalizeOptionalText(input.groupId) ?? undefined, + hardwareId: this.normalizeOptionalText(input.hardwareId) ?? undefined, + } + } + + private normalizeUpdateHoleInput(input: UpdateHoleInput): UpdateHoleInput { + const normalX = input.normalX === undefined ? undefined : this.normalizeFiniteNumber(input.normalX, '孔轴 X') + const normalY = input.normalY === undefined ? undefined : this.normalizeFiniteNumber(input.normalY, '孔轴 Y') + const normalZ = input.normalZ === undefined ? undefined : this.normalizeFiniteNumber(input.normalZ, '孔轴 Z') + if (normalX !== undefined || normalY !== undefined || normalZ !== undefined) { + this.assertNonZeroNormal(normalX ?? 0, normalY ?? 0, normalZ ?? 1) + } + + return { + ...input, + ...(input.positionX !== undefined ? { positionX: this.normalizeFiniteNumber(input.positionX, '孔位 X 坐标') } : {}), + ...(input.positionY !== undefined ? { positionY: this.normalizeFiniteNumber(input.positionY, '孔位 Y 坐标') } : {}), + ...(input.positionZ !== undefined ? { positionZ: this.normalizeFiniteNumber(input.positionZ, '孔位 Z 坐标') } : {}), + ...(input.diameter !== undefined ? { diameter: this.normalizePositiveNumber(input.diameter, '孔径') } : {}), + ...(input.depth !== undefined ? { depth: this.normalizeNonNegativeNumber(input.depth, '孔深') } : {}), + ...(normalX !== undefined ? { normalX } : {}), + ...(normalY !== undefined ? { normalY } : {}), + ...(normalZ !== undefined ? { normalZ } : {}), + ...(input.holeType !== undefined ? { holeType: this.normalizeHoleType(input.holeType) } : {}), + ...(input.confidence !== undefined ? { confidence: this.normalizeConfidence(input.confidence) } : {}), + ...(input.groupId !== undefined ? { groupId: this.normalizeOptionalText(input.groupId) } : {}), + ...(input.hardwareId !== undefined ? { hardwareId: this.normalizeOptionalText(input.hardwareId) } : {}), + } + } + + private normalizeFiniteNumber(value: number, fieldName: string): number { + if (!Number.isFinite(value)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须是有效数字`) + } + return value + } + + private normalizePositiveNumber(value: number, fieldName: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须大于 0`) + } + return value + } + + private normalizeNonNegativeNumber(value: number, fieldName: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}不能为负数`) + } + return value + } + + private normalizeConfidence(value: number): number { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '孔位置信度必须在 0 到 1 之间') + } + return value + } + + private assertNonZeroNormal(normalX: number, normalY: number, normalZ: number): void { + if (normalX === 0 && normalY === 0 && normalZ === 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '孔轴法线不能为零向量') + } + } + + private normalizeHoleType(value: string | null | undefined): string | null { + const normalized = value?.trim() ?? '' + if (!normalized) return null + if (!HOLE_TYPES.has(normalized)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '孔位类型无效') + } + return normalized + } + + private normalizeOptionalText(value: string | null | undefined): string | null { + const normalized = value?.trim() ?? '' + return normalized || null + } + private async getProductForPermission(productId: string): Promise { const product = await productRepository.findByIdForCollaborators(productId) if (!product || product.deletedAt) { diff --git a/furniture-3d-system/src/server/services/hole.service.types.ts b/furniture-3d-system/src/server/services/hole.service.types.ts index 031d5590..2dfc77c2 100644 --- a/furniture-3d-system/src/server/services/hole.service.types.ts +++ b/furniture-3d-system/src/server/services/hole.service.types.ts @@ -64,7 +64,7 @@ export interface CreateHoleInput { normalX?: number normalY?: number normalZ?: number - holeType?: string + holeType?: string | null confidence?: number isManual?: boolean groupId?: string @@ -80,7 +80,7 @@ export interface UpdateHoleInput { normalX?: number normalY?: number normalZ?: number - holeType?: string + holeType?: string | null confidence?: number isManual?: boolean groupId?: string | null diff --git a/furniture-3d-system/src/server/services/index.ts b/furniture-3d-system/src/server/services/index.ts index 7464d463..035fde8b 100644 --- a/furniture-3d-system/src/server/services/index.ts +++ b/furniture-3d-system/src/server/services/index.ts @@ -192,6 +192,7 @@ export type { ImportAssetCadInput, SupportedAssetImportType, } from './asset-cad-import.service' +export { productPatentService, ProductPatentService } from './product-patent.service' export { draftGroupService, DraftGroupService } from './draft-group.service' export type { @@ -233,6 +234,21 @@ export { intakeNonIntegratedProductService, IntakeNonIntegratedProductService, } from './intake-non-integrated-product.service' +export { + intakeReferenceLibraryService, + IntakeReferenceLibraryService, + INTAKE_REFERENCE_LIBRARY_TYPES, +} from './intake-reference-library.service' +export type { + IntakeReferenceLibraryItemDTO, + IntakeReferenceLibraryType, + ReplaceIntakeReferenceLibraryInput, +} from './intake-reference-library.service' +export { + spuNumberReservationService, + SpuNumberReservationService, +} from './spu-number-reservation.service' +export type { SpuNumberReservation } from './spu-number-reservation.service' export { warehouseService, WarehouseService } from './warehouse.service' export type { diff --git a/furniture-3d-system/src/server/services/intake-contract-docx.service.ts b/furniture-3d-system/src/server/services/intake-contract-docx.service.ts index 9e27a275..9b465e79 100644 --- a/furniture-3d-system/src/server/services/intake-contract-docx.service.ts +++ b/furniture-3d-system/src/server/services/intake-contract-docx.service.ts @@ -10,6 +10,7 @@ import type { NonIntegratedSkuItem, } from "@/app/intake/non-integrated/nonIntegratedStorage"; import { AppError, ErrorCode } from "@/server/core/errors"; +import { assertNonIntegratedProductMasterDataReadyForFormalUse } from "./non-integrated-sku-quality"; export type IntakeProductFactoryContractType = | "prepaid" @@ -145,6 +146,10 @@ export async function createIntakeProductFactoryContractDocx({ batch: IntakeProductFactoryContractBatch; }) { const config = CONTRACT_TEMPLATE_CONFIG[contractType]; + assertNonIntegratedProductMasterDataReadyForFormalUse(product, { + batch, + usageLabel: "产品定厂合同", + }); const rows = resolveContractRows(product, batch); if (rows.length === 0) { diff --git a/furniture-3d-system/src/server/services/intake-node-permission-settings.service.ts b/furniture-3d-system/src/server/services/intake-node-permission-settings.service.ts index c4c821c6..31f33c89 100644 --- a/furniture-3d-system/src/server/services/intake-node-permission-settings.service.ts +++ b/furniture-3d-system/src/server/services/intake-node-permission-settings.service.ts @@ -15,6 +15,15 @@ type IntakeNodePermissionOverrideRow = { enabled: boolean; }; +type IntakeNodePermissionOverrideSchemaProbeRow = { + table_exists: boolean; + unique_index_exists: boolean; + target_index_exists: boolean; + subject_index_exists: boolean; + identity_constraint_exists: boolean; + audit_constraint_exists: boolean; +}; + const ADMIN_ROLES = new Set(["ADMIN", "SUPER_ADMIN"]); const INTAKE_NODE_PERMISSION_SETTINGS_LOCK_KEY = 2026052311; @@ -31,45 +40,51 @@ function buildOverrideKey(row: IntakeNodePermissionOverrideRow) { } class IntakeNodePermissionSettingsService { - private ensureTablePromise: Promise | null = null; + private ensureSchemaReadyPromise: Promise | null = null; - private async ensureTable() { - if (!this.ensureTablePromise) { - this.ensureTablePromise = (async () => { - await prisma.$executeRawUnsafe(` - CREATE TABLE IF NOT EXISTS "IntakeNodePermissionOverride" ( - "id" TEXT NOT NULL, - "targetKey" TEXT NOT NULL, - "subjectKey" TEXT NOT NULL, - "action" TEXT NOT NULL, - "enabled" BOOLEAN NOT NULL DEFAULT true, - "createdBy" TEXT, - "updatedBy" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "IntakeNodePermissionOverride_pkey" PRIMARY KEY ("id") - ) - `); - await prisma.$executeRawUnsafe(` - CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_subjectKey_action_key" - ON "IntakeNodePermissionOverride"("targetKey", "subjectKey", "action") - `); - await prisma.$executeRawUnsafe(` - CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_idx" - ON "IntakeNodePermissionOverride"("targetKey") - `); - await prisma.$executeRawUnsafe(` - CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_subjectKey_idx" - ON "IntakeNodePermissionOverride"("subjectKey") - `); + private async ensureSchemaReady() { + if (!this.ensureSchemaReadyPromise) { + this.ensureSchemaReadyPromise = (async () => { + const [schema] = + await prisma.$queryRaw` + SELECT + to_regclass('public."IntakeNodePermissionOverride"') IS NOT NULL AS table_exists, + to_regclass('public."IntakeNodePermissionOverride_targetKey_subjectKey_action_key"') IS NOT NULL AS unique_index_exists, + to_regclass('public."IntakeNodePermissionOverride_targetKey_idx"') IS NOT NULL AS target_index_exists, + to_regclass('public."IntakeNodePermissionOverride_subjectKey_idx"') IS NOT NULL AS subject_index_exists, + EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'IntakeNodePermissionOverride_identity_valid' + ) AS identity_constraint_exists, + EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'IntakeNodePermissionOverride_audit_text_not_blank' + ) AS audit_constraint_exists + `; + + if ( + !schema?.table_exists || + !schema.unique_index_exists || + !schema.target_index_exists || + !schema.subject_index_exists || + !schema.identity_constraint_exists || + !schema.audit_constraint_exists + ) { + throw new AppError( + ErrorCode.SYSTEM_DATABASE, + "产品资料节点权限表结构未完成迁移,请先执行数据库迁移和主数据 strict 巡检", + ); + } })(); } - await this.ensureTablePromise; + await this.ensureSchemaReadyPromise; } async getSettings() { - await this.ensureTable(); + await this.ensureSchemaReady(); const rows = await prisma.$queryRaw` SELECT "targetKey", "subjectKey", "action", "enabled" @@ -90,7 +105,7 @@ class IntakeNodePermissionSettingsService { async updateSettings(input: unknown, user: AuthUser) { assertCanManageSettings(user); - await this.ensureTable(); + await this.ensureSchemaReady(); const inputRecord = input && typeof input === "object" && !Array.isArray(input) diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 08b46d83..010116c0 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -2,19 +2,24 @@ import { randomUUID } from 'crypto' import { Prisma } from '@prisma/client' import { NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + NON_INTEGRATED_DEFAULT_BRAND, NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES, applyBrandPrefixToNonIntegratedCode, + buildNonIntegratedProductId, createDefaultDocumentWorkspaceConfirmations as createSharedDocumentWorkspaceConfirmations, + normalizeNonIntegratedBrand, normalizeNonIntegratedProductRecord, resolveNonIntegratedPersistedSpu, stripNonIntegratedBrandPrefix, type NonIntegratedDocumentWorkspaceApprovalMap, type NonIntegratedDocumentWorkspaceConfirmationMap, type NonIntegratedProductRecord, + type NonIntegratedSkuItem, type NonIntegratedUploadFile, } from '@/app/intake/non-integrated/nonIntegratedStorage' import { normalizeNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl' +import { prisma } from '@/lib/prisma' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' import { @@ -25,11 +30,16 @@ import { draftGroupRepository, preSkuRepository, } from '../repositories/draft-group.repository' +import { + assertNonIntegratedProductMasterDataReadyForFormalUse, + resolveNonIntegratedSkuQuality, +} from './non-integrated-sku-quality' import { DEFAULT_NON_INTEGRATED_VISIBILITY, intakeNonIntegratedScopeService, } from './intake-non-integrated-scope.service' import { skuQualityInspectionService } from './sku-quality-inspection.service' +import { spuNumberReservationService } from './spu-number-reservation.service' type NonIntegratedUploadPresentationPreview = { pdfUrl: string @@ -96,7 +106,7 @@ type SavePresentationPreviewInput = SharedUploadFileSelector & { type IntakeNonIntegratedRecord = NonNullable< Awaited> -> +> | NonNullable>> type NonIntegratedWorkspaceCategoryKey = keyof NonIntegratedDocumentWorkspaceConfirmationMap @@ -120,8 +130,12 @@ type RemovedSourceSkuDraftGroupTarget = { type SaveNonIntegratedProductOptions = { workspaceCategory?: string | null + formalSpuAssignmentProductCategory?: string | null } +type StructuredNonIntegratedSkuItemCreateInput = + Prisma.IntakeNonIntegratedSkuItemCreateManyProductInput + export type IntakeNonIntegratedProductListParams = { page?: number pageSize?: number @@ -249,6 +263,81 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function readText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeSkuVersionNo(value: unknown) { + const normalizedValue = readText(value).toUpperCase() + if (!normalizedValue) return '' + + const numericMatch = normalizedValue.match(/^V?(\d+)$/) + if (!numericMatch) return normalizedValue + + const numericValue = Number.parseInt(numericMatch[1], 10) + if (!Number.isFinite(numericValue) || numericValue <= 0) return '' + + return `V${String(numericValue).padStart(2, '0')}` +} + +function buildAutoSkuCode(spu: string, sequence: unknown, packageSuffix: unknown) { + const normalizedSpu = spu.trim().toUpperCase() + if (!normalizedSpu) return '' + + const numericSequence = + typeof sequence === 'number' ? sequence : Number.parseInt(String(sequence ?? ''), 10) + const safeSequence = + Number.isFinite(numericSequence) && numericSequence > 0 ? Math.trunc(numericSequence) : 1 + const skuCode = `${normalizedSpu}${String(safeSequence).padStart(2, '0')}` + return `${skuCode}${readText(packageSuffix).toUpperCase()}` +} + +function buildAutoBarcode(skuCode: string, manufacturerCode: unknown, versionNo: string) { + const normalizedSkuCode = skuCode.trim().toUpperCase() + if (!normalizedSkuCode) return '' + + return `${normalizedSkuCode}${readText(manufacturerCode).toUpperCase()}${versionNo}` +} + +function normalizePreservedNonIntegratedBrand(value: string | null | undefined) { + return readText(value) || NON_INTEGRATED_DEFAULT_BRAND +} + +function normalizePreservedNonIntegratedSpu(record: { id: string; spu: string }) { + return readText(record.spu).toUpperCase() || record.id.trim().toUpperCase() +} + +function shouldPreserveFormalSkuCode(skuCode: string, formalSpu: string) { + const normalizedSkuCode = skuCode.trim().toUpperCase() + const normalizedFormalSpu = formalSpu.trim().toUpperCase() + return Boolean(normalizedSkuCode && normalizedFormalSpu && normalizedSkuCode.startsWith(normalizedFormalSpu)) +} + +function assignFormalSpuToSkuItems(value: unknown, formalSpu: string) { + if (!Array.isArray(value)) return value + + return value.map((item, index) => { + if (!isRecord(item)) return item + + const versionNo = normalizeSkuVersionNo(item.versionNo) || 'V01' + const existingSkuCode = readText(item.skuCode).toUpperCase() + const skuCode = shouldPreserveFormalSkuCode(existingSkuCode, formalSpu) + ? existingSkuCode + : buildAutoSkuCode(formalSpu, item.skuSequence ?? index + 1, item.packageSuffix) + const existingBarcode = readText(item.barcode).toUpperCase() + const barcode = existingBarcode && existingBarcode.startsWith(skuCode) + ? existingBarcode + : buildAutoBarcode(skuCode, item.manufacturerCode, versionNo) + + return { + ...item, + skuCode, + barcode, + versionNo, + } + }) +} + function createDefaultDocumentWorkspaceConfirmations( value = true, ): NonIntegratedDocumentWorkspaceConfirmationMap { @@ -1351,17 +1440,70 @@ function canTreatPlanningLayoutSaveAsNoop( ) } +function canTreatStockReadonlyCompatibilitySaveAsNoop( + isStockProduct: boolean, + changedCategories: Set, + writableCategories: Set, +) { + if (!isStockProduct || !writableCategories.has(PLANNING_LAYOUT_WORKSPACE_CATEGORY)) { + return false + } + + return Array.from(changedCategories).every((category) => + category === NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY || + category === OPENING_REFERENCE_WORKSPACE_CATEGORY, + ) +} + function throwNonIntegratedWriteDenied(): never { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限编辑这条非集成产品记录') } -function isStockRecordPayload(record: IntakeNonIntegratedRecord) { +function applyPersistedRecordIdentity( + product: SharedNonIntegratedProductRecord | null, + record: IntakeNonIntegratedRecord, +) { + if (!product) return product + + const persistedSpu = readText(record.spu).toUpperCase() + const persistedBrand = normalizePreservedNonIntegratedBrand(record.brand) + + return { + ...product, + id: record.id, + spu: persistedSpu || product.spu, + productCode: persistedSpu || product.productCode, + brand: persistedBrand, + designer: readText(record.designer) || product.designer, + operator: readText(record.operator) || product.operator, + purchaser: readText(record.purchaser) || product.purchaser, + } +} + +function buildStructuredProductRecordFromRecord(record: IntakeNonIntegratedRecord) { + const source = record as IntakeNonIntegratedRecord & StructuredHydrationSource const product = buildHydratedProductRecord(record.payload, { createdAt: record.createdAt, updatedAt: record.updatedAt, createdBy: record.createdBy, updatedBy: record.updatedBy, }) + const hasStructuredSource = + Boolean(source.profile) || + (Array.isArray(source.skuItems) && source.skuItems.length > 0) + + const hydratedProduct = hasStructuredSource + ? applyStructuredProductData(product, { + profile: source.profile ?? null, + skuItems: source.skuItems ?? null, + }) + : product + + return applyPersistedRecordIdentity(hydratedProduct, record) +} + +function isStockRecord(record: IntakeNonIntegratedRecord) { + const product = buildStructuredProductRecordFromRecord(record) return product ? isStockNonIntegratedProduct(product) : false } @@ -1370,7 +1512,7 @@ function assertStockWorkspaceWriteCategory( record: IntakeNonIntegratedRecord, category?: string | null, ) { - if (!isStockRecordPayload(record)) return + if (!isStockRecord(record)) return const resolvedCategory = toDocumentWorkspaceCategory(category ?? undefined) if (resolvedCategory === PLANNING_LAYOUT_WORKSPACE_CATEGORY) return @@ -1384,12 +1526,7 @@ async function prepareProductForPermittedWrite( nextProduct: SharedNonIntegratedProductRecord, options: SaveNonIntegratedProductOptions = {}, ): Promise { - const existingProduct = buildHydratedProductRecord(record.payload, { - createdAt: record.createdAt, - updatedAt: record.updatedAt, - createdBy: record.createdBy, - updatedBy: record.updatedBy, - }) + const existingProduct = buildStructuredProductRecordFromRecord(record) if (!existingProduct) { await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) @@ -1466,6 +1603,11 @@ async function prepareProductForPermittedWrite( canTreatPlanningLayoutSaveAsNoop( options.workspaceCategory, writableCategories, + ) || + canTreatStockReadonlyCompatibilitySaveAsNoop( + isStockProduct, + changedCategories, + writableCategories, ) ) { return { @@ -1712,6 +1854,181 @@ function buildHydratedProductRecord( }) } +type StructuredHydrationSource = { + profile?: Record | null + skuItems?: Array> | null +} + +function readStructuredText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function readStructuredNumber(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback +} + +function assignStructuredField>( + target: T, + key: keyof T & string, + source: Record, + sourceKey: string = key, +) { + if (!Object.prototype.hasOwnProperty.call(source, sourceKey)) return + target[key] = readStructuredText(source[sourceKey]) as T[typeof key] +} + +function buildStructuredSkuItemOverlay( + product: SharedNonIntegratedProductRecord, + skuRows: Array>, +): NonIntegratedSkuItem[] { + if (skuRows.length === 0) return product.skuItems + + const legacyBySkuItemId = new Map(product.skuItems.map((item) => [item.id, item])) + const legacyByBarcode = new Map( + product.skuItems + .filter((item) => item.barcode.trim()) + .map((item) => [item.barcode.trim(), item]), + ) + + return skuRows + .slice() + .sort((left, right) => readStructuredNumber(left.sortOrder, 0) - readStructuredNumber(right.sortOrder, 0)) + .map((row, index) => { + const skuItemId = readStructuredText(row.skuItemId) || `sku-${index + 1}` + const barcode = readStructuredText(row.barcode) + const legacy = legacyBySkuItemId.get(skuItemId) ?? legacyByBarcode.get(barcode) + + return { + id: skuItemId, + skuSequence: readStructuredNumber(row.skuSequence, legacy?.skuSequence ?? index + 1), + batchNo: readStructuredNumber(row.batchNo, legacy?.batchNo ?? 1), + packageGroupId: readStructuredText(row.packageGroupId) || legacy?.packageGroupId || '', + packageSuffix: legacy?.packageSuffix ?? '', + image: readStructuredText(row.image) || legacy?.image || '', + imageName: readStructuredText(row.imageName) || legacy?.imageName || '', + model: readStructuredText(row.model), + skuCode: readStructuredText(row.skuCode), + setCount: legacy?.setCount || '1', + specName: readStructuredText(row.specName), + barcode, + size: readStructuredText(row.size), + color: readStructuredText(row.color), + material: readStructuredText(row.material), + boardMaterialType: readStructuredText(row.boardMaterialType) || legacy?.boardMaterialType || '', + versionNo: readStructuredText(row.versionNo) || 'V01', + attributeCode: readStructuredText(row.attributeCode), + drawingId: readStructuredText(row.drawingId), + installMethod: readStructuredText(row.installMethod), + executeStandard: readStructuredText(row.executeStandard), + nationalStandardCompliant: readStructuredText(row.nationalStandardCompliant), + manufacturer: readStructuredText(row.manufacturer), + manufacturerCode: readStructuredText(row.manufacturerCode), + salesStatus: readStructuredText(row.salesStatus), + tagMaterial: readStructuredText(row.tagMaterial), + model3dFileName: legacy?.model3dFileName ?? '', + model3dDataUrl: readStructuredText(row.model3dDataUrl) || legacy?.model3dDataUrl || '', + model3dNodeOffsets: legacy?.model3dNodeOffsets, + model3dNodeRotations: legacy?.model3dNodeRotations, + model3dNodeMaterialIds: legacy?.model3dNodeMaterialIds, + model3dUvConfig: legacy?.model3dUvConfig, + componentDetails: legacy?.componentDetails ?? [], + holePoints: legacy?.holePoints ?? [], + source: readStructuredText(row.source), + sourceTable: readStructuredText(row.sourceTable), + sourceSkuId: readStructuredText(row.sourceSkuId), + contentLocked: row.contentLocked === true, + } + }) +} + +function applyStructuredProductData( + product: SharedNonIntegratedProductRecord | null, + source?: StructuredHydrationSource | null, +) { + if (!product || !source) return product + + const profile = source.profile + const nextProduct: SharedNonIntegratedProductRecord = { + ...product, + openingReference: { + ...product.openingReference, + }, + } + + if (profile) { + assignStructuredField(nextProduct.openingReference, 'productName', profile) + assignStructuredField(nextProduct.openingReference, 'productImage', profile, 'productImageUrl') + assignStructuredField(nextProduct.openingReference, 'productCategory', profile) + assignStructuredField(nextProduct.openingReference, 'salesCategory', profile) + assignStructuredField(nextProduct.openingReference, 'currentStage', profile, 'productStatus') + assignStructuredField(nextProduct.openingReference, 'year', profile, 'productYear') + assignStructuredField(nextProduct.openingReference, 'planningOwner', profile, 'productPlanning') + assignStructuredField(nextProduct.openingReference, 'structureDesigner', profile) + + assignStructuredField(nextProduct, 'productDevelopmentKind', profile) + assignStructuredField(nextProduct, 'productCategory', profile) + assignStructuredField(nextProduct, 'salesCategory', profile) + assignStructuredField(nextProduct, 'year', profile, 'productYear') + assignStructuredField(nextProduct, 'goodsType', profile) + assignStructuredField(nextProduct, 'materialType', profile) + assignStructuredField(nextProduct, 'productionType', profile) + assignStructuredField(nextProduct, 'size', profile) + assignStructuredField(nextProduct, 'color', profile) + assignStructuredField(nextProduct, 'tagMaterial', profile) + assignStructuredField(nextProduct, 'priceBand', profile) + assignStructuredField(nextProduct, 'demandFlowNo', profile) + assignStructuredField(nextProduct, 'processTemplate', profile) + assignStructuredField(nextProduct, 'productPlanning', profile) + assignStructuredField(nextProduct, 'brandPlanning', profile) + assignStructuredField(nextProduct, 'demandDepartment', profile) + assignStructuredField(nextProduct, 'spuOwner', profile) + assignStructuredField(nextProduct, 'operationsOwner', profile) + assignStructuredField(nextProduct, 'purchaseOwner', profile) + assignStructuredField(nextProduct, 'visualDirector', profile) + assignStructuredField(nextProduct, 'visualPlanning', profile) + + const imageUrl = readStructuredText(profile.productImageUrl) + if (Object.prototype.hasOwnProperty.call(profile, 'productImageUrl')) { + nextProduct.previewImage = imageUrl + } + + const stockMetadataEntries = { + sourceSpu: profile.sourceSpu, + sourceSkuId: profile.sourceSkuId, + sourceCategory: profile.sourceCategory, + sourceItemType: profile.sourceItemType, + sourceSalesStatus: profile.sourceSalesStatus, + sourceEnabled: profile.sourceEnabled, + sourceStockDisabled: profile.sourceStockDisabled, + } + const stockMetadataKeys = Object.keys(stockMetadataEntries) + const baseMetadata = isRecord(nextProduct.metadata) ? { ...nextProduct.metadata } : {} + stockMetadataKeys.forEach((key) => { + delete baseMetadata[key] + }) + const stockMetadata = Object.fromEntries( + Object.entries(stockMetadataEntries) + .map(([key, value]) => [key, readStructuredText(value)]) + .filter(([, value]) => value), + ) + nextProduct.metadata = { + ...baseMetadata, + ...stockMetadata, + } + } + + const skuRows = Array.isArray(source.skuItems) ? source.skuItems : [] + if (skuRows.length > 0) { + nextProduct.skuItems = buildStructuredSkuItemOverlay(nextProduct, skuRows) + nextProduct.skuBatchCount = Math.max( + nextProduct.skuBatchCount ?? 1, + ...nextProduct.skuItems.map((item) => item.batchNo || 1), + ) + } + + return normalizeSharedProductSpuByBrand(nextProduct) +} + function hasMeaningfulBomDetails( bomDetails: NonIntegratedProductRecord['bomDetails'] | undefined, ) { @@ -1943,6 +2260,93 @@ function jsonTextMissingOrNotEquals( } } +function profileTextEquals( + field: string, + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + profile: { + is: { + [field]: value, + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput +} + +function profileTextContains( + field: string, + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + profile: { + is: { + [field]: { + contains: value, + mode: 'insensitive', + }, + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput +} + +function profileTextNonEmpty(field: string): Prisma.IntakeNonIntegratedProductWhereInput { + return { + profile: { + is: { + [field]: { + not: '', + }, + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput +} + +function payloadJsonWhenProfileMissing( + condition: Prisma.IntakeNonIntegratedProductWhereInput, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + AND: [ + { + profile: null, + }, + condition, + ], + } +} + +function profileTextNotEqualsWhenPresent( + field: string, + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + AND: [ + { + NOT: { + profile: null, + }, + }, + { + OR: [ + { + NOT: profileTextEquals(field, value), + }, + { + profile: { + is: { + [field]: '', + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput, + ], + }, + ], + } +} + +function payloadTextContainsWhenProfileMissing(path: string[], keyword: string) { + return payloadJsonWhenProfileMissing(jsonTextContains(path, keyword)) +} + function buildTextContainsAny( value: string, conditions: Prisma.IntakeNonIntegratedProductWhereInput[], @@ -1970,7 +2374,10 @@ function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput { return { OR: [ { id: { startsWith: 'jst-stock-' } }, - ...JST_SKU_SOURCE_PATHS.map((path) => jsonTextEquals([...path], 'jst_sku')), + profileTextNonEmpty('sourceSpu'), + ...JST_SKU_SOURCE_PATHS.map((path) => + payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')), + ), ], } } @@ -1979,21 +2386,19 @@ function buildDevelopmentKindWhere( productDevelopmentKind: IntakeNonIntegratedProductListParams['productDevelopmentKind'], ): Prisma.IntakeNonIntegratedProductWhereInput | null { if (productDevelopmentKind === 'series') { - return jsonTextEquals(['productDevelopmentKind'], 'series') + return { + OR: [ + profileTextEquals('productDevelopmentKind', 'series'), + payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')), + ], + } } if (productDevelopmentKind === 'product') { return { OR: [ - { - NOT: jsonTextEquals(['productDevelopmentKind'], 'series'), - }, - { - payload: { - path: ['productDevelopmentKind'], - equals: Prisma.AnyNull, - }, - }, + profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'), + payloadJsonWhenProfileMissing(jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series')), ], } } @@ -2012,18 +2417,7 @@ function buildProductKindWhere( if (productKind === 'new') { return { - AND: [ - { - id: { - not: { - startsWith: 'jst-stock-', - }, - }, - }, - ...JST_SKU_SOURCE_PATHS.map((path) => - jsonTextMissingOrNotEquals([...path], 'jst_sku'), - ), - ], + NOT: stockWhere, } } @@ -2041,28 +2435,56 @@ function buildSpuSearchWhere(spu: string): Prisma.IntakeNonIntegratedProductWher OR: [ { spu: { contains: normalizedKeyword, mode: 'insensitive' } }, { spuBase: { contains: baseKeyword || normalizedKeyword, mode: 'insensitive' } }, - jsonTextContains(['openingReference', 'productName'], keyword), - jsonTextContains(['openingReference', 'temporaryCode'], keyword), - jsonTextContains(['metadata', 'sourceSpu'], keyword), - jsonTextContains(['metadata', 'sourceSkuId'], keyword), + profileTextContains('productName', keyword), + profileTextContains('sourceSpu', keyword), + profileTextContains('sourceSkuId', keyword), + payloadJsonWhenProfileMissing(jsonTextContains(['openingReference', 'productName'], keyword)), + payloadJsonWhenProfileMissing(jsonTextContains(['openingReference', 'temporaryCode'], keyword)), + payloadJsonWhenProfileMissing(jsonTextContains(['metadata', 'sourceSpu'], keyword)), + payloadJsonWhenProfileMissing(jsonTextContains(['metadata', 'sourceSkuId'], keyword)), ], } } function buildStockTextSearchWhere( value: string | undefined, - paths: string[][], + options: { + profileFields?: string[] + jsonPaths?: string[][] + includeStructuredSkuBarcode?: boolean + }, ): Prisma.IntakeNonIntegratedProductWhereInput | null { const keyword = trimListFilter(value) if (!keyword) return null + const conditions: Prisma.IntakeNonIntegratedProductWhereInput[] = [ + ...(options.profileFields ?? []).map((field) => profileTextContains(field, keyword)), + ...(options.jsonPaths ?? []).map((path) => + payloadJsonWhenProfileMissing(jsonTextContains(path, keyword)), + ), + ] + + if (options.includeStructuredSkuBarcode) { + conditions.push({ + skuItems: { + some: { + barcode: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + }) + } + return { - OR: paths.map((path) => jsonTextContains(path, keyword)), + OR: conditions, } } function buildStockExactWhere( value: string | undefined, + profileField: string, path: string[], ): Prisma.IntakeNonIntegratedProductWhereInput | null { const keyword = trimListFilter(value) @@ -2070,14 +2492,24 @@ function buildStockExactWhere( if (keyword === '__empty') { return { - payload: { - path, - equals: Prisma.AnyNull, - }, + OR: [ + profileTextEquals(profileField, ''), + payloadJsonWhenProfileMissing({ + payload: { + path, + equals: Prisma.AnyNull, + }, + }), + ], } } - return jsonTextEquals(path, keyword) + return { + OR: [ + profileTextEquals(profileField, keyword), + payloadJsonWhenProfileMissing(jsonTextEquals(path, keyword)), + ], + } } function buildStockDataScopeWhere( @@ -2086,9 +2518,24 @@ function buildStockDataScopeWhere( const scope = trimListFilter(value) if (!scope || scope === 'all') return null - const normalSpuWhere: Prisma.IntakeNonIntegratedProductWhereInput = { + const profileNormalSpuWhere: Prisma.IntakeNonIntegratedProductWhereInput = { AND: [ - jsonTextEquals(['metadata', 'sourceItemType'], '成品'), + profileTextEquals('sourceItemType', '成品'), + profileTextNonEmpty('sourceCategory'), + { + NOT: profileTextContains('sourceCategory', '其他 -'), + }, + ], + } + + const payloadNormalSpuWhere: Prisma.IntakeNonIntegratedProductWhereInput = { + AND: [ + { + payload: { + path: ['metadata', 'sourceItemType'], + equals: '成品', + }, + }, { NOT: { payload: { @@ -2098,10 +2545,22 @@ function buildStockDataScopeWhere( }, }, { - NOT: jsonTextContains(['metadata', 'sourceCategory'], '其他 -'), + NOT: { + payload: { + path: ['metadata', 'sourceCategory'], + string_contains: '其他 -', + mode: 'insensitive', + }, + }, }, ], } + const normalSpuWhere: Prisma.IntakeNonIntegratedProductWhereInput = { + OR: [ + profileNormalSpuWhere, + payloadJsonWhenProfileMissing(payloadNormalSpuWhere), + ], + } if (scope === 'normalSpu') { return normalSpuWhere @@ -2111,8 +2570,18 @@ function buildStockDataScopeWhere( return { AND: [ normalSpuWhere, - jsonTextEquals(['metadata', 'sourceSalesStatus'], '销售'), - jsonTextEquals(['metadata', 'sourceEnabled'], '1'), + { + OR: [ + profileTextEquals('sourceSalesStatus', '销售'), + payloadJsonWhenProfileMissing(jsonTextEquals(['metadata', 'sourceSalesStatus'], '销售')), + ], + }, + { + OR: [ + profileTextEquals('sourceEnabled', '1'), + payloadJsonWhenProfileMissing(jsonTextEquals(['metadata', 'sourceEnabled'], '1')), + ], + }, ], } } @@ -2124,16 +2593,23 @@ function buildStockSourceSearchWhere( params: IntakeNonIntegratedProductListParams, ): Prisma.IntakeNonIntegratedProductWhereInput[] { return [ - buildStockTextSearchWhere(params.stockSku, [ - ['metadata', 'sourceSkuId'], - ['skuItems', '0', 'barcode'], - ]), - buildStockTextSearchWhere(params.stockCategory, [ - ['metadata', 'sourceCategory'], - ]), - buildStockExactWhere(params.stockItemType, ['metadata', 'sourceItemType']), - buildStockExactWhere(params.stockSalesStatus, ['metadata', 'sourceSalesStatus']), - buildStockExactWhere(params.stockEnabledStatus, ['metadata', 'sourceEnabled']), + buildStockTextSearchWhere(params.stockSku, { + profileFields: ['sourceSkuId'], + jsonPaths: [ + ['metadata', 'sourceSkuId'], + ['skuItems', '0', 'barcode'], + ], + includeStructuredSkuBarcode: true, + }), + buildStockTextSearchWhere(params.stockCategory, { + profileFields: ['sourceCategory'], + jsonPaths: [ + ['metadata', 'sourceCategory'], + ], + }), + buildStockExactWhere(params.stockItemType, 'sourceItemType', ['metadata', 'sourceItemType']), + buildStockExactWhere(params.stockSalesStatus, 'sourceSalesStatus', ['metadata', 'sourceSalesStatus']), + buildStockExactWhere(params.stockEnabledStatus, 'sourceEnabled', ['metadata', 'sourceEnabled']), buildStockDataScopeWhere(params.stockDataScope), ].filter((filter): filter is Prisma.IntakeNonIntegratedProductWhereInput => filter !== null) } @@ -2150,29 +2626,37 @@ function buildPeopleSearchWhere( const filters: Array = [ buildTextContainsAny(appearanceDesigner, [ { designer: { contains: appearanceDesigner, mode: 'insensitive' } }, - jsonTextContains(['designer'], appearanceDesigner), - jsonTextContains(['openingReference', 'designer'], appearanceDesigner), + payloadTextContainsWhenProfileMissing(['designer'], appearanceDesigner), + payloadTextContainsWhenProfileMissing(['openingReference', 'designer'], appearanceDesigner), ]), buildTextContainsAny(structureDesigner, [ - jsonTextContains(['openingReference', 'structureDesigner'], structureDesigner), + profileTextContains('structureDesigner', structureDesigner), + payloadTextContainsWhenProfileMissing(['openingReference', 'structureDesigner'], structureDesigner), ]), buildTextContainsAny(spuOwner, [ { operator: { contains: spuOwner, mode: 'insensitive' } }, - jsonTextContains(['spuOwner'], spuOwner), - jsonTextContains(['operationsOwner'], spuOwner), + profileTextContains('spuOwner', spuOwner), + profileTextContains('operationsOwner', spuOwner), + payloadTextContainsWhenProfileMissing(['spuOwner'], spuOwner), + payloadTextContainsWhenProfileMissing(['operationsOwner'], spuOwner), ]), buildTextContainsAny(planningOwner, [ - jsonTextContains(['productPlanning'], planningOwner), - jsonTextContains(['brandPlanning'], planningOwner), - jsonTextContains(['openingReference', 'planningOwner'], planningOwner), + profileTextContains('productPlanning', planningOwner), + profileTextContains('brandPlanning', planningOwner), + payloadTextContainsWhenProfileMissing(['productPlanning'], planningOwner), + payloadTextContainsWhenProfileMissing(['brandPlanning'], planningOwner), + payloadTextContainsWhenProfileMissing(['openingReference', 'planningOwner'], planningOwner), ]), buildTextContainsAny(spaceDesigner, [ - jsonTextContains(['visualPlanning'], spaceDesigner), - jsonTextContains(['visualDirector'], spaceDesigner), + profileTextContains('visualPlanning', spaceDesigner), + profileTextContains('visualDirector', spaceDesigner), + payloadTextContainsWhenProfileMissing(['visualPlanning'], spaceDesigner), + payloadTextContainsWhenProfileMissing(['visualDirector'], spaceDesigner), ]), buildTextContainsAny(purchaseOwner, [ { purchaser: { contains: purchaseOwner, mode: 'insensitive' } }, - jsonTextContains(['purchaseOwner'], purchaseOwner), + profileTextContains('purchaseOwner', purchaseOwner), + payloadTextContainsWhenProfileMissing(['purchaseOwner'], purchaseOwner), ]), ] @@ -2223,39 +2707,47 @@ function buildResponsibleOwnerSearchWhere( if (hasListBusinessRole(user, 'DESIGN')) { if (isListSpaceDesignUser(user)) { conditions.push( - jsonTextContains(['visualPlanning'], userName), - jsonTextContains(['visualDirector'], userName), + profileTextContains('visualPlanning', userName), + profileTextContains('visualDirector', userName), + payloadTextContainsWhenProfileMissing(['visualPlanning'], userName), + payloadTextContainsWhenProfileMissing(['visualDirector'], userName), ) } else { conditions.push( { designer: { contains: userName, mode: 'insensitive' } }, - jsonTextContains(['designer'], userName), - jsonTextContains(['openingReference', 'designer'], userName), - jsonTextContains(['openingReference', 'structureDesigner'], userName), + profileTextContains('structureDesigner', userName), + payloadTextContainsWhenProfileMissing(['designer'], userName), + payloadTextContainsWhenProfileMissing(['openingReference', 'designer'], userName), + payloadTextContainsWhenProfileMissing(['openingReference', 'structureDesigner'], userName), ) } } if (hasListBusinessRole(user, 'PLANNING')) { conditions.push( - jsonTextContains(['productPlanning'], userName), - jsonTextContains(['brandPlanning'], userName), - jsonTextContains(['openingReference', 'planningOwner'], userName), + profileTextContains('productPlanning', userName), + profileTextContains('brandPlanning', userName), + payloadTextContainsWhenProfileMissing(['productPlanning'], userName), + payloadTextContainsWhenProfileMissing(['brandPlanning'], userName), + payloadTextContainsWhenProfileMissing(['openingReference', 'planningOwner'], userName), ) } if (hasListBusinessRole(user, 'PURCHASING')) { conditions.push( { purchaser: { contains: userName, mode: 'insensitive' } }, - jsonTextContains(['purchaseOwner'], userName), + profileTextContains('purchaseOwner', userName), + payloadTextContainsWhenProfileMissing(['purchaseOwner'], userName), ) } if (isListOperationsOwnerUser(user)) { conditions.push( { operator: { contains: userName, mode: 'insensitive' } }, - jsonTextContains(['spuOwner'], userName), - jsonTextContains(['operationsOwner'], userName), + profileTextContains('spuOwner', userName), + profileTextContains('operationsOwner', userName), + payloadTextContainsWhenProfileMissing(['spuOwner'], userName), + payloadTextContainsWhenProfileMissing(['operationsOwner'], userName), ) } @@ -2264,17 +2756,25 @@ function buildResponsibleOwnerSearchWhere( { designer: { contains: userName, mode: 'insensitive' } }, { operator: { contains: userName, mode: 'insensitive' } }, { purchaser: { contains: userName, mode: 'insensitive' } }, - jsonTextContains(['designer'], userName), - jsonTextContains(['openingReference', 'designer'], userName), - jsonTextContains(['openingReference', 'structureDesigner'], userName), - jsonTextContains(['spuOwner'], userName), - jsonTextContains(['operationsOwner'], userName), - jsonTextContains(['productPlanning'], userName), - jsonTextContains(['brandPlanning'], userName), - jsonTextContains(['openingReference', 'planningOwner'], userName), - jsonTextContains(['visualPlanning'], userName), - jsonTextContains(['visualDirector'], userName), - jsonTextContains(['purchaseOwner'], userName), + profileTextContains('structureDesigner', userName), + profileTextContains('spuOwner', userName), + profileTextContains('operationsOwner', userName), + profileTextContains('productPlanning', userName), + profileTextContains('brandPlanning', userName), + profileTextContains('visualPlanning', userName), + profileTextContains('visualDirector', userName), + profileTextContains('purchaseOwner', userName), + payloadTextContainsWhenProfileMissing(['designer'], userName), + payloadTextContainsWhenProfileMissing(['openingReference', 'designer'], userName), + payloadTextContainsWhenProfileMissing(['openingReference', 'structureDesigner'], userName), + payloadTextContainsWhenProfileMissing(['spuOwner'], userName), + payloadTextContainsWhenProfileMissing(['operationsOwner'], userName), + payloadTextContainsWhenProfileMissing(['productPlanning'], userName), + payloadTextContainsWhenProfileMissing(['brandPlanning'], userName), + payloadTextContainsWhenProfileMissing(['openingReference', 'planningOwner'], userName), + payloadTextContainsWhenProfileMissing(['visualPlanning'], userName), + payloadTextContainsWhenProfileMissing(['visualDirector'], userName), + payloadTextContainsWhenProfileMissing(['purchaseOwner'], userName), ) } @@ -2373,10 +2873,50 @@ function pickUploadFileMetadata(input: AttachUploadFileInput): AttachUploadFileM } } -function isSpuUniqueConstraintError(error: unknown) { +function isSpuUniqueConstraintError( + error: unknown, +): error is Prisma.PrismaClientKnownRequestError { return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' } +function getPrismaUniqueConstraintTarget(error: Prisma.PrismaClientKnownRequestError) { + const target = (error.meta as { target?: unknown } | null)?.target + if (Array.isArray(target)) { + return target.join(',') + } + return typeof target === 'string' ? target : '' +} + +function toNonIntegratedDuplicateConstraintError(error: unknown) { + if (!isSpuUniqueConstraintError(error)) { + return null + } + + const target = getPrismaUniqueConstraintTarget(error) + if ( + target.includes('IntakeNonIntegratedSkuItem_skuCode') || + target.includes('skuCode') + ) { + return new AppError(ErrorCode.VALIDATION_FAILED, 'SKU 编码已存在,请检查后重新保存') + } + + if ( + target.includes('IntakeNonIntegratedSkuItem_barcode') || + target.includes('barcode') + ) { + return new AppError(ErrorCode.VALIDATION_FAILED, 'SKU 条码已存在,请检查后重新保存') + } + + if (target.includes('spu') || target.includes('brand')) { + return new AppError( + ErrorCode.VALIDATION_FAILED, + NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, + ) + } + + return new AppError(ErrorCode.VALIDATION_FAILED, '编码或条码已存在,请检查后重新保存') +} + async function assertNoDuplicateProduct( repository: IntakeNonIntegratedProductRepositoryClass, product: DuplicateProductCheckInput, @@ -2545,6 +3085,219 @@ async function deleteDraftGroupsAfterSourceSkuRemoval( ) } +async function assertNoDuplicateProductInTransaction( + repository: IntakeNonIntegratedProductRepositoryClass, + tx: Prisma.TransactionClient, + product: DuplicateProductCheckInput, +) { + const duplicateRecord = await repository.findBySpuInTransaction( + tx, + product.spu, + product.id, + product.brand, + ) + if (duplicateRecord) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, + ) + } +} + +function trimSkuText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeSkuIdentityText(value: unknown) { + return trimSkuText(value).toUpperCase() +} + +export function buildStructuredSkuItemInputs( + product: NonIntegratedProductRecord & Record, +): StructuredNonIntegratedSkuItemCreateInput[] { + const seenSkuItemIds = new Map() + + return product.skuItems.map((item, index) => { + const rawSkuItemId = trimSkuText(item.id) || `sku-${index + 1}` + const seenCount = seenSkuItemIds.get(rawSkuItemId) ?? 0 + seenSkuItemIds.set(rawSkuItemId, seenCount + 1) + const skuItemId = seenCount === 0 ? rawSkuItemId : `${rawSkuItemId}-${seenCount + 1}` + const quality = resolveNonIntegratedSkuQuality(item) + + return { + id: `${product.id}:${skuItemId}`, + skuItemId, + sortOrder: index + 1, + skuSequence: Number.isFinite(item.skuSequence) ? Math.trunc(item.skuSequence) : 0, + batchNo: Number.isFinite(item.batchNo) ? Math.max(1, Math.trunc(item.batchNo)) : 1, + packageGroupId: trimSkuText(item.packageGroupId), + image: trimSkuText(item.image) || null, + imageName: trimSkuText(item.imageName), + model: trimSkuText(item.model), + skuCode: normalizeSkuIdentityText(item.skuCode), + specName: trimSkuText(item.specName), + barcode: normalizeSkuIdentityText(item.barcode), + size: trimSkuText(item.size), + color: trimSkuText(item.color), + material: trimSkuText(item.material), + boardMaterialType: trimSkuText(item.boardMaterialType), + versionNo: trimSkuText(item.versionNo) || 'V01', + attributeCode: trimSkuText(item.attributeCode), + drawingId: trimSkuText(item.drawingId), + installMethod: trimSkuText(item.installMethod), + executeStandard: trimSkuText(item.executeStandard), + nationalStandardCompliant: trimSkuText(item.nationalStandardCompliant), + manufacturer: trimSkuText(item.manufacturer), + manufacturerCode: trimSkuText(item.manufacturerCode), + salesStatus: trimSkuText(item.salesStatus), + tagMaterial: trimSkuText(item.tagMaterial), + model3dDataUrl: trimSkuText(item.model3dDataUrl) || null, + source: trimSkuText(item.source), + sourceTable: trimSkuText(item.sourceTable), + sourceSkuId: trimSkuText(item.sourceSkuId), + contentLocked: item.contentLocked === true, + ...quality, + } + }) +} + +function readOpeningReferenceField( + product: NonIntegratedProductRecord & Record, + key: string, +) { + const openingReference = isRecord(product.openingReference) + ? product.openingReference + : {} + return trimSkuText(openingReference[key]) +} + +function readProductMetadataField( + product: NonIntegratedProductRecord & Record, + key: string, +) { + const metadata = isRecord(product.metadata) ? product.metadata : {} + return trimSkuText(metadata[key]) +} + +function getStructuredSkuItemCount(product: NonIntegratedProductRecord & Record) { + return Array.isArray(product.skuItems) ? product.skuItems.length : 0 +} + +export function buildStructuredProductProfileInput( + product: NonIntegratedProductRecord & Record, +): Prisma.IntakeNonIntegratedProductProfileCreateWithoutProductInput { + const sourceSalesStatus = readProductMetadataField(product, 'sourceSalesStatus') + + return { + productName: + readOpeningReferenceField(product, 'productName') || + trimSkuText(product.previewImageName) || + trimSkuText(product.spu), + productImageUrl: + readOpeningReferenceField(product, 'productImage') || + trimSkuText(product.previewImage) || + null, + productCategory: + readOpeningReferenceField(product, 'productCategory') || + trimSkuText(product.productCategory), + salesCategory: + readOpeningReferenceField(product, 'salesCategory') || + trimSkuText(product.salesCategory), + productYear: + readOpeningReferenceField(product, 'year') || + trimSkuText(product.year), + productStatus: + readOpeningReferenceField(product, 'currentStage') || + trimSkuText(product.projectStatus), + productDevelopmentKind: trimSkuText(product.productDevelopmentKind), + goodsType: trimSkuText(product.goodsType), + materialType: trimSkuText(product.materialType), + productionType: trimSkuText(product.productionType), + size: trimSkuText(product.size) || readOpeningReferenceField(product, 'size'), + color: trimSkuText(product.color), + tagMaterial: trimSkuText(product.tagMaterial), + priceBand: + trimSkuText(product.priceBand) || + readOpeningReferenceField(product, 'targetPriceBand'), + demandFlowNo: trimSkuText(product.demandFlowNo), + processTemplate: trimSkuText(product.processTemplate), + productPlanning: + trimSkuText(product.productPlanning) || + readOpeningReferenceField(product, 'planningOwner'), + brandPlanning: trimSkuText(product.brandPlanning), + demandDepartment: trimSkuText(product.demandDepartment), + spuOwner: trimSkuText(product.spuOwner), + operationsOwner: trimSkuText(product.operationsOwner), + purchaseOwner: + trimSkuText(product.purchaseOwner) || + trimSkuText(product.purchaser), + visualDirector: trimSkuText(product.visualDirector), + visualPlanning: trimSkuText(product.visualPlanning), + structureDesigner: readOpeningReferenceField(product, 'structureDesigner'), + sourceSpu: readProductMetadataField(product, 'sourceSpu'), + sourceSkuId: readProductMetadataField(product, 'sourceSkuId'), + sourceCategory: readProductMetadataField(product, 'sourceCategory'), + sourceItemType: readProductMetadataField(product, 'sourceItemType'), + sourceSalesStatus, + sourceEnabled: readProductMetadataField(product, 'sourceEnabled'), + sourceStockDisabled: readProductMetadataField(product, 'sourceStockDisabled'), + stockSkuCount: getStructuredSkuItemCount(product), + stockHasOnSaleSku: sourceSalesStatus === '销售', + source: 'PAYLOAD_SYNC', + syncedAt: new Date(), + } +} + +function withStructuredSkuItemsForCreate( + data: Prisma.IntakeNonIntegratedProductCreateInput, + product: SharedNonIntegratedProductRecord, +): Prisma.IntakeNonIntegratedProductCreateInput { + const skuItemInputs = buildStructuredSkuItemInputs(product) + + return { + ...data, + profile: { + create: buildStructuredProductProfileInput(product), + }, + ...(skuItemInputs.length > 0 + ? { + skuItems: { + createMany: { + data: skuItemInputs, + }, + }, + } + : {}), + } +} + +function withStructuredSkuItemsForUpdate( + data: Prisma.IntakeNonIntegratedProductUpdateInput, + product: SharedNonIntegratedProductRecord, +): Prisma.IntakeNonIntegratedProductUpdateInput { + const skuItemInputs = buildStructuredSkuItemInputs(product) + + return { + ...data, + profile: { + upsert: { + create: buildStructuredProductProfileInput(product), + update: buildStructuredProductProfileInput(product), + }, + }, + skuItems: { + deleteMany: {}, + ...(skuItemInputs.length > 0 + ? { + createMany: { + data: skuItemInputs, + }, + } + : {}), + }, + } +} + class IntakeNonIntegratedProductService { private readonly repository: IntakeNonIntegratedProductRepositoryClass = intakeNonIntegratedProductRepository @@ -2554,14 +3307,7 @@ class IntakeNonIntegratedProductService { intakeNonIntegratedScopeService.buildReadScopeWhere(user), ) return records - .map((record) => - buildHydratedProductRecord(record.payload, { - createdAt: record.createdAt, - updatedAt: record.updatedAt, - createdBy: record.createdBy, - updatedBy: record.updatedBy, - }), - ) + .map((record) => buildStructuredProductRecordFromRecord(record)) .filter((record): record is SharedNonIntegratedProductRecord => record !== null) } @@ -2575,14 +3321,7 @@ class IntakeNonIntegratedProductService { filterWhere: buildListFilterWhere(params, user), }) const products = records - .map((record) => - buildHydratedProductRecord(record.payload, { - createdAt: record.createdAt, - updatedAt: record.updatedAt, - createdBy: record.createdBy, - updatedBy: record.updatedBy, - }), - ) + .map((record) => buildStructuredProductRecordFromRecord(record)) .filter((record): record is SharedNonIntegratedProductRecord => record !== null) return { @@ -2607,13 +3346,12 @@ class IntakeNonIntegratedProductService { const categories = new Set() records.forEach((record) => { - if (!isRecord(record.payload)) return - const metadata = record.payload.metadata - if (!isRecord(metadata)) return - const category = typeof metadata.sourceCategory === 'string' - ? metadata.sourceCategory.trim() + const categoryFromProfile = isRecord(record.profile) + ? readStructuredText(record.profile.sourceCategory) : '' - if (category) categories.add(category) + if (categoryFromProfile) { + categories.add(categoryFromProfile) + } }) return Array.from(categories).sort((left, right) => @@ -2631,13 +3369,7 @@ class IntakeNonIntegratedProductService { return null } - const product = buildHydratedProductRecord(record.payload, { - createdAt: record.createdAt, - updatedAt: record.updatedAt, - createdBy: record.createdBy, - updatedBy: record.updatedBy, - }) - + const product = buildStructuredProductRecordFromRecord(record) return product ? await hydrateProductSkuModelSnapshots(product) : null } @@ -2675,11 +3407,298 @@ class IntakeNonIntegratedProductService { ) } + private buildProductInputWithAssignedFormalSpu( + productInput: Record, + formalSpu: string, + preservedProductId?: string, + ) { + const openingReference = isRecord(productInput.openingReference) + ? productInput.openingReference + : {} + const normalizedBrand = + normalizeNonIntegratedBrand(readText(productInput.brand)) || + normalizeNonIntegratedBrand(readText(openingReference.brand)) || + NON_INTEGRATED_DEFAULT_BRAND + const assignedSpu = applyBrandPrefixToNonIntegratedCode(formalSpu, normalizedBrand) + const currentProductCode = readText(productInput.productCode) + + return { + ...productInput, + id: preservedProductId || buildNonIntegratedProductId(assignedSpu), + spu: assignedSpu, + productCode: currentProductCode || assignedSpu, + brand: normalizedBrand, + formalSpuAssigned: true, + skuItems: assignFormalSpuToSkuItems(productInput.skuItems, assignedSpu), + openingReference: { + ...openingReference, + brand: readText(openingReference.brand) || normalizedBrand, + }, + } + } + + private async saveWithFormalSpuAssignment( + productInput: unknown, + user: AuthUser, + options: SaveNonIntegratedProductOptions, + ) { + if (!isRecord(productInput)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存') + } + + if (readText(productInput.spu)) { + return this.save(productInput, user, { + workspaceCategory: options.workspaceCategory, + }) + } + + const openingReference = isRecord(productInput.openingReference) + ? productInput.openingReference + : {} + const productCategory = + readText(options.formalSpuAssignmentProductCategory) || + readText(productInput.productCategory) || + readText(openingReference.productCategory) + if (!productCategory) { + throw new AppError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '产品类目不能为空,无法在保存时分配正式 SPU', + ) + } + + const savedProductId = await prisma.$transaction(async (tx) => { + const existingRecord = readText(productInput.id) + ? await tx.intakeNonIntegratedProduct.findUnique({ + where: { id: readText(productInput.id) }, + include: { + profile: true, + skuItems: { + orderBy: { + sortOrder: 'asc', + }, + }, + }, + }) + : null + const activeExistingRecord = + existingRecord && !existingRecord.deletedAt + ? (existingRecord as IntakeNonIntegratedRecord) + : null + const reservation = await spuNumberReservationService.reserveInTransaction(tx, { + productCategory, + currentProductId: readText(productInput.id) || null, + userId: user.id, + }) + const assignedProductInput = this.buildProductInputWithAssignedFormalSpu( + productInput, + reservation.spu, + activeExistingRecord?.id, + ) + const normalizedProduct = buildPersistedProductPayload( + assignedProductInput, + existingRecord?.payload, + existingRecord?.createdAt.toISOString(), + ) + if (!normalizedProduct) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存') + } + assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, { + usageLabel: '正式 SPU 分配', + }) + + const normalizedSpu = resolveNonIntegratedPersistedSpu(normalizedProduct) + const deletedRecordToRestore = activeExistingRecord + ? null + : await this.repository.findDeletedBySpuInTransaction( + tx, + normalizedSpu, + normalizedProduct.id, + normalizedProduct.brand, + ) + + if ( + !activeExistingRecord && + !deletedRecordToRestore && + !intakeNonIntegratedScopeService.canCreateRecord(user) + ) { + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + '当前账号只有查看和下载权限,不能新增非集成产品', + ) + } + + const scopedDepartmentId = intakeNonIntegratedScopeService.getScopedDepartmentId(user) + const scopedTeamId = intakeNonIntegratedScopeService.getScopedTeamId(user) + const persistedProduct = activeExistingRecord + ? { + ...normalizedProduct, + id: activeExistingRecord.id, + spu: normalizedSpu, + createdBy: activeExistingRecord.createdBy, + updatedBy: user.id, + } + : deletedRecordToRestore + ? { + ...normalizedProduct, + id: deletedRecordToRestore.id, + spu: normalizedSpu, + createdBy: user.id, + updatedBy: user.id, + } + : { + ...normalizedProduct, + spu: normalizedSpu, + createdBy: user.id, + updatedBy: user.id, + } + + const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu) + await assertNoDuplicateProductInTransaction(this.repository, tx, { + id: normalizedProduct.id, + spu: normalizedSpu, + brand: normalizedProduct.brand, + }) + + if (activeExistingRecord) { + const permittedWrite = await prepareProductForPermittedWrite( + activeExistingRecord, + user, + persistedProduct, + options, + ) + const permittedProduct = permittedWrite.product + const permittedSpu = resolveNonIntegratedPersistedSpu(permittedProduct) + const permittedSpuBase = stripNonIntegratedBrandPrefix(permittedSpu) + const preservedSpu = normalizePreservedNonIntegratedSpu(activeExistingRecord) + const preservedSpuBase = stripNonIntegratedBrandPrefix(preservedSpu) + const preservedBrand = normalizePreservedNonIntegratedBrand(activeExistingRecord.brand) + const preserveIdentityColumns = + permittedWrite.preserveRecordColumns && + !options.formalSpuAssignmentProductCategory + await assertNoDuplicateProductInTransaction(this.repository, tx, { + id: permittedProduct.id, + spu: preserveIdentityColumns ? preservedSpu : permittedSpu, + brand: preserveIdentityColumns + ? preservedBrand + : permittedProduct.brand, + }) + + if (!permittedWrite.skipUpdate) { + await this.repository.updateInTransaction( + tx, + activeExistingRecord.id, + withStructuredSkuItemsForUpdate( + { + spu: preserveIdentityColumns + ? preservedSpu + : permittedSpu, + spuBase: preserveIdentityColumns + ? preservedSpuBase + : permittedSpuBase, + brand: preserveIdentityColumns + ? preservedBrand + : permittedProduct.brand ?? '', + designer: permittedWrite.preserveRecordColumns + ? activeExistingRecord.designer + : permittedProduct.designer, + operator: permittedWrite.preserveRecordColumns + ? activeExistingRecord.operator + : permittedProduct.operator, + purchaser: permittedWrite.preserveRecordColumns + ? activeExistingRecord.purchaser + : permittedProduct.purchaser, + payload: toJsonValue(permittedProduct), + updatedBy: user.id, + deletedAt: null, + ...(activeExistingRecord.createdBy === user.id && + !activeExistingRecord.departmentId && + scopedDepartmentId + ? { departmentId: scopedDepartmentId } + : {}), + ...(activeExistingRecord.createdBy === user.id && + !activeExistingRecord.teamId && + scopedTeamId + ? { teamId: scopedTeamId } + : {}), + }, + permittedProduct, + ), + ) + } + + return activeExistingRecord.id + } + + if (deletedRecordToRestore) { + await this.repository.updateInTransaction( + tx, + deletedRecordToRestore.id, + withStructuredSkuItemsForUpdate( + { + spu: normalizedSpu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, + departmentId: scopedDepartmentId ?? null, + teamId: scopedTeamId ?? null, + createdBy: user.id, + updatedBy: user.id, + createdAt: new Date(persistedProduct.createdAt), + deletedAt: null, + }, + persistedProduct, + ), + ) + return deletedRecordToRestore.id + } + + await this.repository.createInTransaction( + tx, + withStructuredSkuItemsForCreate( + { + id: persistedProduct.id, + spu: normalizedSpu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, + ...(scopedDepartmentId ? { departmentId: scopedDepartmentId } : {}), + ...(scopedTeamId ? { teamId: scopedTeamId } : {}), + createdBy: user.id, + updatedBy: user.id, + createdAt: new Date(persistedProduct.createdAt), + }, + persistedProduct, + ), + ) + + return persistedProduct.id + }) + + const savedProduct = await this.getById(savedProductId, user) + if (!savedProduct) { + throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品保存后读取失败') + } + + return savedProduct + } + async save( productInput: unknown, user: AuthUser, options: SaveNonIntegratedProductOptions = {}, ) { + if (options.formalSpuAssignmentProductCategory) { + return this.saveWithFormalSpuAssignment(productInput, user, options) + } + const existingRecordCandidate = isRecord(productInput) && typeof productInput.id === 'string' ? await this.repository.findById(productInput.id) : null @@ -2752,11 +3771,14 @@ class IntakeNonIntegratedProductService { const permittedProduct = permittedWrite.product const permittedSpu = resolveNonIntegratedPersistedSpu(permittedProduct) const permittedSpuBase = stripNonIntegratedBrandPrefix(permittedSpu) + const preservedSpu = normalizePreservedNonIntegratedSpu(existingRecord) + const preservedSpuBase = stripNonIntegratedBrandPrefix(preservedSpu) + const preservedBrand = normalizePreservedNonIntegratedBrand(existingRecord.brand) await assertNoDuplicateProduct(this.repository, { id: permittedProduct.id, spu: permittedSpu, brand: permittedWrite.preserveRecordColumns - ? existingRecord.brand + ? preservedBrand : permittedProduct.brand, }) @@ -2773,35 +3795,41 @@ class IntakeNonIntegratedProductService { } if (!permittedWrite.skipUpdate) { - await this.repository.update(existingRecord.id, { - spu: permittedWrite.preserveRecordColumns ? existingRecord.spu : permittedSpu, - spuBase: permittedWrite.preserveRecordColumns - ? existingRecord.spuBase - : permittedSpuBase, - brand: permittedWrite.preserveRecordColumns - ? existingRecord.brand - : permittedProduct.brand ?? '', - designer: permittedWrite.preserveRecordColumns - ? existingRecord.designer - : permittedProduct.designer, - operator: permittedWrite.preserveRecordColumns - ? existingRecord.operator - : permittedProduct.operator, - purchaser: permittedWrite.preserveRecordColumns - ? existingRecord.purchaser - : permittedProduct.purchaser, - payload: toJsonValue(permittedProduct), - updatedBy: user.id, - deletedAt: null, - ...(existingRecord.createdBy === user.id && - !existingRecord.departmentId && - scopedDepartmentId - ? { departmentId: scopedDepartmentId } - : {}), - ...(existingRecord.createdBy === user.id && !existingRecord.teamId && scopedTeamId - ? { teamId: scopedTeamId } - : {}), - }) + await this.repository.update( + existingRecord.id, + withStructuredSkuItemsForUpdate( + { + spu: permittedWrite.preserveRecordColumns ? preservedSpu : permittedSpu, + spuBase: permittedWrite.preserveRecordColumns + ? preservedSpuBase + : permittedSpuBase, + brand: permittedWrite.preserveRecordColumns + ? preservedBrand + : permittedProduct.brand ?? '', + designer: permittedWrite.preserveRecordColumns + ? existingRecord.designer + : permittedProduct.designer, + operator: permittedWrite.preserveRecordColumns + ? existingRecord.operator + : permittedProduct.operator, + purchaser: permittedWrite.preserveRecordColumns + ? existingRecord.purchaser + : permittedProduct.purchaser, + payload: toJsonValue(permittedProduct), + updatedBy: user.id, + deletedAt: null, + ...(existingRecord.createdBy === user.id && + !existingRecord.departmentId && + scopedDepartmentId + ? { departmentId: scopedDepartmentId } + : {}), + ...(existingRecord.createdBy === user.id && !existingRecord.teamId && scopedTeamId + ? { teamId: scopedTeamId } + : {}), + }, + permittedProduct, + ), + ) } } else if (deletedRecordToRestore) { const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu) @@ -2811,22 +3839,28 @@ class IntakeNonIntegratedProductService { brand: normalizedProduct.brand, }) - await this.repository.update(deletedRecordToRestore.id, { - spu: normalizedSpu, - spuBase: normalizedSpuBase, - brand: persistedProduct.brand ?? '', - designer: persistedProduct.designer, - operator: persistedProduct.operator, - purchaser: persistedProduct.purchaser, - payload: toJsonValue(persistedProduct), - visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, - departmentId: scopedDepartmentId ?? null, - teamId: scopedTeamId ?? null, - createdBy: user.id, - updatedBy: user.id, - createdAt: new Date(persistedProduct.createdAt), - deletedAt: null, - }) + await this.repository.update( + deletedRecordToRestore.id, + withStructuredSkuItemsForUpdate( + { + spu: normalizedSpu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, + departmentId: scopedDepartmentId ?? null, + teamId: scopedTeamId ?? null, + createdBy: user.id, + updatedBy: user.id, + createdAt: new Date(persistedProduct.createdAt), + deletedAt: null, + }, + persistedProduct, + ), + ) } else { const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu) await assertNoDuplicateProduct(this.repository, { @@ -2835,29 +3869,32 @@ class IntakeNonIntegratedProductService { brand: normalizedProduct.brand, }) - await this.repository.create({ - id: persistedProduct.id, - spu: normalizedSpu, - spuBase: normalizedSpuBase, - brand: persistedProduct.brand ?? '', - designer: persistedProduct.designer, - operator: persistedProduct.operator, - purchaser: persistedProduct.purchaser, - payload: toJsonValue(persistedProduct), - visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, - ...(scopedDepartmentId ? { departmentId: scopedDepartmentId } : {}), - ...(scopedTeamId ? { teamId: scopedTeamId } : {}), - createdBy: user.id, - updatedBy: user.id, - createdAt: new Date(persistedProduct.createdAt), - }) + await this.repository.create( + withStructuredSkuItemsForCreate( + { + id: persistedProduct.id, + spu: normalizedSpu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, + ...(scopedDepartmentId ? { departmentId: scopedDepartmentId } : {}), + ...(scopedTeamId ? { teamId: scopedTeamId } : {}), + createdBy: user.id, + updatedBy: user.id, + createdAt: new Date(persistedProduct.createdAt), + }, + persistedProduct, + ), + ) } } catch (error) { - if (isSpuUniqueConstraintError(error)) { - throw new AppError( - ErrorCode.VALIDATION_FAILED, - NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, - ) + const duplicateConstraintError = toNonIntegratedDuplicateConstraintError(error) + if (duplicateConstraintError) { + throw duplicateConstraintError } throw error @@ -2906,12 +3943,7 @@ class IntakeNonIntegratedProductService { assertStockWorkspaceWriteCategory(existingRecord, input.category) await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, input.category) - const payload = buildHydratedProductRecord(existingRecord.payload, { - createdAt: existingRecord.createdAt, - updatedAt: existingRecord.updatedAt, - createdBy: existingRecord.createdBy, - updatedBy: existingRecord.updatedBy, - }) + const payload = buildStructuredProductRecordFromRecord(existingRecord) if (!payload) { throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品数据异常,无法写入上传文件') } @@ -2964,7 +3996,7 @@ class IntakeNonIntegratedProductService { hasPendingDocumentWorkspaceConfirmation(nextDocumentWorkspaceConfirmations), } - await this.repository.updatePayloadByUser(productId, toJsonValue(nextPayload), user.id) + await this.repository.updateAttachmentPayloadByUser(productId, toJsonValue(nextPayload), user.id) return { product: @@ -2990,12 +4022,7 @@ class IntakeNonIntegratedProductService { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') } - const payload = buildHydratedProductRecord(existingRecord.payload, { - createdAt: existingRecord.createdAt, - updatedAt: existingRecord.updatedAt, - createdBy: existingRecord.createdBy, - updatedBy: existingRecord.updatedBy, - }) + const payload = buildStructuredProductRecordFromRecord(existingRecord) if (!payload) { throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品数据异常,无法读取上传文件') } @@ -3048,7 +4075,7 @@ class IntakeNonIntegratedProductService { hasPendingDocumentWorkspaceConfirmation(product.documentWorkspaceConfirmations), } - await this.repository.updatePayloadByUser(productId, toJsonValue(nextPayload), user.id) + await this.repository.updateAttachmentPayloadByUser(productId, toJsonValue(nextPayload), user.id) return { product: diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts index 32d0814a..e4312c1f 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts @@ -1,4 +1,4 @@ -import { Prisma, type IntakeNonIntegratedProduct } from '@prisma/client' +import { Prisma, type IntakeNonIntegratedProduct, type IntakeNonIntegratedProductProfile } from '@prisma/client' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' import { @@ -23,11 +23,12 @@ type IntakeNonIntegratedPermissionTarget = Pick< | 'createdBy' | 'designer' | 'purchaser' - | 'payload' | 'visibility' | 'departmentId' | 'teamId' -> +> & { + profile?: IntakeNonIntegratedProductProfile | null +} const ADMIN_ROLES = new Set(['SUPER_ADMIN', 'ADMIN']) export const DEFAULT_NON_INTEGRATED_VISIBILITY = 'DEPARTMENT' @@ -115,40 +116,35 @@ class IntakeNonIntegratedScopeService { private buildPermissionProduct( record: IntakeNonIntegratedPermissionTarget, ): IntakeNonIntegratedPermissionProduct { - const payload = - typeof record.payload === 'object' && record.payload !== null && !Array.isArray(record.payload) - ? (record.payload as Record) - : {} - const openingReference = - typeof payload.openingReference === 'object' && - payload.openingReference !== null && - !Array.isArray(payload.openingReference) - ? (payload.openingReference as IntakeNonIntegratedPermissionProduct['openingReference']) - : null + const profile = record.profile ?? null + const readProfile = (key: keyof IntakeNonIntegratedProductProfile) => { + const value = profile?.[key] + return typeof value === 'string' ? value : '' + } + const nextOpeningReference = { + productName: readProfile('productName'), + productImage: readProfile('productImageUrl'), + productCategory: readProfile('productCategory'), + salesCategory: readProfile('salesCategory'), + currentStage: readProfile('productStatus'), + year: readProfile('productYear'), + structureDesigner: readProfile('structureDesigner'), + planningOwner: readProfile('productPlanning'), + } return { createdBy: record.createdBy, - designer: - typeof payload.designer === 'string' ? payload.designer : record.designer, - purchaser: - typeof payload.purchaser === 'string' ? payload.purchaser : record.purchaser, - purchaseOwner: - typeof payload.purchaseOwner === 'string' ? payload.purchaseOwner : '', - brandPlanning: - typeof payload.brandPlanning === 'string' ? payload.brandPlanning : '', - spuOwner: - typeof payload.spuOwner === 'string' ? payload.spuOwner : '', - operator: - typeof payload.operator === 'string' ? payload.operator : '', - operationsOwner: - typeof payload.operationsOwner === 'string' ? payload.operationsOwner : '', - productPlanning: - typeof payload.productPlanning === 'string' ? payload.productPlanning : '', - visualPlanning: - typeof payload.visualPlanning === 'string' ? payload.visualPlanning : '', - visualDirector: - typeof payload.visualDirector === 'string' ? payload.visualDirector : '', - openingReference, + designer: record.designer, + purchaser: record.purchaser, + purchaseOwner: readProfile('purchaseOwner'), + brandPlanning: readProfile('brandPlanning'), + spuOwner: readProfile('spuOwner'), + operator: '', + operationsOwner: readProfile('operationsOwner'), + productPlanning: readProfile('productPlanning'), + visualPlanning: readProfile('visualPlanning'), + visualDirector: readProfile('visualDirector'), + openingReference: nextOpeningReference, } } diff --git a/furniture-3d-system/src/server/services/intake.service.ts b/furniture-3d-system/src/server/services/intake.service.ts index 6ebd2067..968ecb31 100644 --- a/furniture-3d-system/src/server/services/intake.service.ts +++ b/furniture-3d-system/src/server/services/intake.service.ts @@ -8,9 +8,9 @@ import { assetVariantRepository, preSkuRepository, productRepository, - skuRepository, warehouseLocationRepository, } from '../repositories' +import type { AssetVariantWithRelations, PreSkuWithRelations } from '../repositories' import type { FormalizeDraftGroupInput, FormalizeDraftGroupResult, @@ -18,12 +18,55 @@ import type { } from './intake.service.types' import type { PreSkuSelectedSlotVariantDTO, PreSkuSelectedHardwareSelectionDTO } from './draft-group.service.types' import { normalizeAssemblyWorkspace, normalizeVariantSelection } from './draft-group.mapper' -import { buildAssetSkuSnapshot } from './asset-sku-snapshot' +import { buildAssetSkuSnapshot, buildProductSkuAssetProfileData } from './asset-sku-snapshot' +import { releaseSkuCodeFromDeletedRecordsTx } from './sku-soft-delete.helper' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function readText(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeFormalIdentityCode(value: string): string { + return value.trim().toUpperCase() +} + +const REQUIRED_PRE_SKU_BASIC_INFO_FIELDS: Array<{ field: string; label: string }> = [ + { field: 'model', label: '型号' }, + { field: 'formalSkuCode', label: '规格码' }, + { field: 'barcode', label: '条码' }, + { field: 'specName', label: '规格名称' }, + { field: 'versionNo', label: '版本号' }, +] + +function readPreSkuBasicInfo( + preSku: { basicInfo?: Record | null }, + field: string, +) { + const structuredValue = readText(preSku.basicInfo?.[field]) + if (structuredValue) return structuredValue + + return '' +} + +function ensurePreSkuBasicInfoReadyForFormalization( + preSku: { basicInfo?: Record | null; customName?: string | null; defaultName: string }, +) { + const missingFields = REQUIRED_PRE_SKU_BASIC_INFO_FIELDS + .filter((item) => !readPreSkuBasicInfo(preSku, item.field)) + .map((item) => item.label) + + if (missingFields.length === 0) return + + const label = preSku.customName?.trim() || preSku.defaultName + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `${label} 缺少结构化基础资料:${missingFields.join('、')};请回到草稿组补齐后再正式入库`, + ) +} + function asArray(value: unknown): T[] { return Array.isArray(value) ? value : [] } @@ -52,10 +95,6 @@ function toVector3Tuple(value: unknown): [number, number, number] | null { return null } -function buildProductModelNumber(): string { - return `SPU-${Date.now()}` -} - function normalizeDimensionSignature(dimensions: Record) { return Object.entries(dimensions) .filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])) @@ -125,12 +164,24 @@ function isDraftGroupEligibleForIntake(status: string) { return status === 'PACKAGING' || status === 'READY_FOR_INTAKE' } +function isValidPackagingBoxNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + function normalizePackagingBoxes(value: unknown) { return asArray>(value) - .filter((item) => typeof item.boxNumber === 'number' && typeof item.name === 'string') + .filter((item) => isValidPackagingBoxNumber(item.boxNumber) && typeof item.name === 'string') .map((item) => ({ boxNumber: item.boxNumber as number, - name: item.name as string, + name: (item.name as string).trim(), outerSizeMm: isRecord(item.outerSizeMm) ? { length: toNullableNumber(item.outerSizeMm.length), @@ -146,6 +197,62 @@ function normalizePackagingBoxes(value: unknown) { })) } +function assertPackagingBoxesReadyForFormalization(value: unknown) { + const boxes = asArray>(value) + if (boxes.length === 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '准 SKU 缺少有效包装箱,当前不能正式入库') + } + + const seenBoxNumbers = new Set() + boxes.forEach((box, index) => { + if (!isValidPackagingBoxNumber(box.boxNumber)) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `第 ${index + 1} 个包装箱编号必须是正整数,当前不能正式入库`, + ) + } + + if (seenBoxNumbers.has(box.boxNumber)) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `包装箱编号 ${box.boxNumber} 重复,当前不能正式入库`, + ) + } + seenBoxNumbers.add(box.boxNumber) + + if (typeof box.name !== 'string') { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `第 ${index + 1} 个包装箱缺少名称,当前不能正式入库`, + ) + } + + const outerSizeMm = isRecord(box.outerSizeMm) ? box.outerSizeMm : null + if ( + !outerSizeMm || + !isPositiveFiniteNumber(outerSizeMm.length) || + !isPositiveFiniteNumber(outerSizeMm.width) || + !isPositiveFiniteNumber(outerSizeMm.height) + ) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `第 ${index + 1} 个包装箱尺寸必须大于 0,当前不能正式入库`, + ) + } + + if ( + box.estimatedWeightKg !== null && + box.estimatedWeightKg !== undefined && + !isNonNegativeFiniteNumber(box.estimatedWeightKg) + ) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `第 ${index + 1} 个包装箱预估重量不能为负数,当前不能正式入库`, + ) + } + }) +} + function normalizePackagingSummary(value: unknown, boxCount: number) { if (!isRecord(value)) { return { @@ -209,25 +316,59 @@ function ensurePreSkuReadyForFormalization(preSku: { } const packagingPlan = isRecord(preSku.packagingPlan) ? preSku.packagingPlan : null - if (!packagingPlan || !hasValidPackagingBoxes(packagingPlan.boxes)) { + if (!packagingPlan) { throw new AppError(ErrorCode.VALIDATION_FAILED, '准 SKU 缺少有效包装箱,当前不能正式入库') } + assertPackagingBoxesReadyForFormalization(packagingPlan.boxes) +} + +async function assertSkuCodeAvailableForFormalization( + skuCode: string, + tx: Prisma.TransactionClient, +): Promise { + const normalizedSkuCode = normalizeFormalIdentityCode(skuCode) + await releaseSkuCodeFromDeletedRecordsTx(tx, normalizedSkuCode) + const existing = await tx.productSKU.findFirst({ + where: { skuCode: normalizedSkuCode, deletedAt: null }, + select: { id: true }, + }) + if (existing) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `正式 SKU 编码「${skuCode}」已存在;请回到草稿组基础资料修正规格码后再入库`, + ) + } } -async function buildUniqueSkuCode( - productId: string, - preferredCode: string, - tx?: Prisma.TransactionClient, -): Promise { - let skuCode = preferredCode - let suffix = 1 - - while (await skuRepository.findByProductIdAndSkuCode(productId, skuCode, tx)) { - skuCode = `${preferredCode}-${suffix}` - suffix += 1 +async function assertProductModelNumberAvailableForFormalization( + modelNumber: string, + tx: Prisma.TransactionClient, +): Promise { + const normalizedModelNumber = normalizeFormalIdentityCode(modelNumber) + const existing = await tx.product.findFirst({ + where: { modelNumber: normalizedModelNumber }, + select: { id: true }, + }) + if (existing) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `正式 SPU 型号「${modelNumber}」已存在;请修正产品资料后再入库`, + ) } +} - return skuCode +function readRequiredFormalProductText( + value: string | undefined, + label: string, + options?: { uppercase?: boolean }, +): string { + const text = value?.trim() ?? '' + if (text) return options?.uppercase ? normalizeFormalIdentityCode(text) : text + + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `新建正式 SPU 必须填写${label},不能由后端临时生成`, + ) } async function buildSimilarityWarningsAndBlockingIssues(input: { @@ -256,7 +397,6 @@ async function buildSimilarityWarningsAndBlockingIssues(input: { modelNumber: true, }, }, - attributes: true, sourcePreSku: { select: { variantSelection: true, @@ -267,15 +407,16 @@ async function buildSimilarityWarningsAndBlockingIssues(input: { }, }) - const existingProfiles = existingSkus.map((sku) => { - const attributes = isRecord(sku.attributes) ? sku.attributes : null - const variantSelection = - attributes?.variantSelectionSummary ?? sku.sourcePreSku?.variantSelection ?? Prisma.JsonNull - const bomPreview = - attributes?.bomPreview ?? sku.sourcePreSku?.bomPreview ?? Prisma.JsonNull + const existingProfiles = existingSkus.flatMap((sku) => { + if (!sku.sourcePreSku) { + return [] + } + + const variantSelection = sku.sourcePreSku.variantSelection ?? Prisma.JsonNull + const bomPreview = sku.sourcePreSku.bomPreview ?? Prisma.JsonNull const profile = buildPreSkuSimilarityProfile({ variantSelection, bomPreview }) - return { + return [{ skuId: sku.id, productId: sku.productId, label: buildSkuDisplayLabel({ @@ -284,7 +425,7 @@ async function buildSimilarityWarningsAndBlockingIssues(input: { skuCode: sku.skuCode, }), ...profile, - } + }] }) const blockingIssues: string[] = [] @@ -387,6 +528,67 @@ function resolveSourceVariantIds(variantSelection: unknown): { } } +function getPreSkuLabel(preSku: { customName?: string | null; defaultName: string }) { + return preSku.customName?.trim() || preSku.defaultName +} + +function getSelectedVariantItems(variantSelection: unknown) { + const selection = normalizeVariantSelection(variantSelection) + return [ + ...selection.slots.map((item) => ({ ...item, sourceGroup: '板材/组件' })), + ...selection.hardwareSelections.map((item) => ({ ...item, sourceGroup: '五金件' })), + ] +} + +function validatePreSkuSourceVariants( + preSku: PreSkuWithRelations, + sourceVariantById: Map, +) { + const label = getPreSkuLabel(preSku) + const selectedItems = getSelectedVariantItems(preSku.variantSelection) + if (selectedItems.length === 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${label} 缺少资产规格款来源,不能正式入库`) + } + + for (const item of selectedItems) { + const itemLabel = + item.name || + item.code || + ('slotKey' in item ? item.slotKey : item.selectionKey) || + item.sourceGroup + const variantId = item.variantId?.trim() + if (!variantId) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${label} 的 ${itemLabel} 缺少规格款 ID,不能正式入库`) + } + + const variant = sourceVariantById.get(variantId) + if (!variant) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${label} 的 ${itemLabel} 引用的规格款不存在,不能正式入库`) + } + if (variant.deletedAt || variant.assetFamily.deletedAt) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${label} 的 ${itemLabel} 引用的规格款已删除,不能正式入库`) + } + if (variant.status !== 'AVAILABLE') { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `${label} 的 ${itemLabel} 引用的规格款当前不是可用状态,不能正式入库`, + ) + } + if (item.assetFamilyId && variant.assetFamilyId !== item.assetFamilyId) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `${label} 的 ${itemLabel} 所属资产族已变化,请回到草稿组重新确认后再入库`, + ) + } + if (item.assetType && variant.assetType !== item.assetType) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `${label} 的 ${itemLabel} 资产类型已变化,请回到草稿组重新确认后再入库`, + ) + } + } +} + /** * 从 PreSku 的 variantSelection 生成板件数据 */ @@ -395,6 +597,7 @@ async function createPanelsFromVariantSelection( productId: string, skuId: string, variantSelection: unknown, + sourceVariantById: Map, ): Promise { if (!isRecord(variantSelection)) { return [] @@ -408,30 +611,39 @@ async function createPanelsFromVariantSelection( if (slot.assetType !== 'BOARD') { continue } + const sourceVariant = sourceVariantById.get(slot.variantId) + const sourceDimensions = isRecord(sourceVariant?.dimensions) ? sourceVariant.dimensions : {} const panel = await tx.panel.create({ data: { productId, skuId, - code: slot.code || `PANEL-${index + 1}`, - name: slot.name || `板件 ${index + 1}`, - gltfNodeName: slot.code || `panel_${index + 1}`, + code: sourceVariant?.code || slot.code || `PANEL-${index + 1}`, + name: sourceVariant?.name || slot.name || `板件 ${index + 1}`, + gltfNodeName: sourceVariant?.code || slot.code || `panel_${index + 1}`, partType: slot.role || null, - length: slot.dimensions.length || 0, - width: slot.dimensions.width || 0, - thickness: slot.dimensions.thickness || 0, + length: toNullableNumber(sourceDimensions.length) ?? slot.dimensions.length ?? 0, + width: toNullableNumber(sourceDimensions.width) ?? slot.dimensions.width ?? 0, + thickness: toNullableNumber(sourceDimensions.thickness) ?? slot.dimensions.thickness ?? 0, installOrder: null, explosionDirection: null, isAnchor: false, - assemblySemantic: slot.assemblyBasis ? (slot.assemblyBasis as Prisma.InputJsonValue) : Prisma.JsonNull, + assemblySemantic: sourceVariant?.assemblyBasis + ? (sourceVariant.assemblyBasis as Prisma.InputJsonValue) + : slot.assemblyBasis + ? (slot.assemblyBasis as Prisma.InputJsonValue) + : Prisma.JsonNull, }, }) createdPanelIds.push(panel.id) // 从 mountPoints 生成孔位 - if (Array.isArray(slot.mountPoints) && slot.mountPoints.length > 0) { - await createHolesFromMountPoints(tx, panel.id, slot.mountPoints) + const mountPoints = Array.isArray(sourceVariant?.mountPoints) + ? sourceVariant.mountPoints + : slot.mountPoints + if (Array.isArray(mountPoints) && mountPoints.length > 0) { + await createHolesFromMountPoints(tx, panel.id, mountPoints) } } @@ -473,6 +685,7 @@ async function createHardwareFromSelections( productId: string, skuId: string, variantSelection: unknown, + sourceVariantById: Map, ): Promise { if (!isRecord(variantSelection)) { return @@ -481,8 +694,12 @@ async function createHardwareFromSelections( const hardwareSelections = asArray(variantSelection.hardwareSelections) for (const selection of hardwareSelections) { - const firstMountPoint = Array.isArray(selection.mountPoints) && selection.mountPoints.length > 0 - ? selection.mountPoints[0] + const sourceVariant = sourceVariantById.get(selection.variantId) + const mountPoints = Array.isArray(sourceVariant?.mountPoints) + ? sourceVariant.mountPoints + : selection.mountPoints + const firstMountPoint = Array.isArray(mountPoints) && mountPoints.length > 0 + ? mountPoints[0] : null const position = isRecord(firstMountPoint) ? (toVector3Tuple(firstMountPoint.position) ?? [0, 0, 0]) @@ -578,6 +795,7 @@ class IntakeService { throw new AppError(ErrorCode.PRE_SKU_NOT_FOUND, '准 SKU 不存在') } ensurePreSkuReadyForFormalization(preSku, draftGroup.id) + ensurePreSkuBasicInfoReadyForFormalization(preSku) return preSku }), ) @@ -610,6 +828,9 @@ class IntakeService { if (input.targetMode === 'ATTACH_PRODUCT' && !targetProduct) { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '目标 SPU 不存在') } + if (input.targetMode === 'ATTACH_PRODUCT' && targetProduct?.deletedAt) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '目标 SPU 已删除,不能继续入库') + } const sourceVariantInfoByPreSkuId = new Map< string, @@ -626,16 +847,34 @@ class IntakeService { const sourceVariants = await assetVariantRepository.findByIdsWithRelationsIncludingDeleted(allSourceVariantIds) const sourceVariantById = new Map(sourceVariants.map((variant) => [variant.id, variant])) + preSkus.forEach((preSku) => { + validatePreSkuSourceVariants(preSku, sourceVariantById) + }) + + const createProductIdentity = + input.targetMode === 'CREATE_PRODUCT' + ? { + modelNumber: readRequiredFormalProductText(input.modelNumber, '型号', { uppercase: true }), + productName: readRequiredFormalProductText(input.productName, '产品名称'), + } + : null const createdPackagedItemIds: string[] = [] return prisma.$transaction(async (tx) => { + if (createProductIdentity) { + await assertProductModelNumberAvailableForFormalization( + createProductIdentity.modelNumber, + tx, + ) + } + const product = targetProduct ?? (await tx.product.create({ data: { - modelNumber: input.modelNumber?.trim() || buildProductModelNumber(), - name: input.productName?.trim() || draftGroup.name, + modelNumber: createProductIdentity?.modelNumber ?? '', + name: createProductIdentity?.productName ?? '', description: `来自草稿组 ${draftGroup.name} 的正式入库结果`, categoryId: input.productCategoryId ?? null, visibility: 'TEAM', @@ -669,11 +908,10 @@ class IntakeService { const costPrice = bomCost + (packagingSummary.totalCost ?? 0) - const preferredSkuCode = - skuItem.skuCode?.trim() || - `${product.modelNumber}-${String(preSku.sequence).padStart(2, '0')}` - const skuCode = await buildUniqueSkuCode(product.id, preferredSkuCode, tx) - const skuName = skuItem.skuName?.trim() || preSku.customName || preSku.defaultName + const preferredSkuCode = normalizeFormalIdentityCode(readPreSkuBasicInfo(preSku, 'formalSkuCode')) + await assertSkuCodeAvailableForFormalization(preferredSkuCode, tx) + const skuCode = preferredSkuCode + const skuName = readPreSkuBasicInfo(preSku, 'specName') const sourceInfo = sourceVariantInfoByPreSkuId.get(preSku.id) ?? { primaryVariantId: null, variantIds: [], @@ -696,13 +934,12 @@ class IntakeService { data: { productId: product.id, skuCode, + name: skuName, attributes: { draftGroupId: draftGroup.id, preSkuId: preSku.id, - skuName, componentCodes: preSku.componentCodes, bomPreview: preSku.bomPreview, - displaySummary: preSku.displaySummary, installStepPreview: preSku.installStepPreview, variantSelectionSummary: isRecord(preSku.variantSelection) ? preSku.variantSelection @@ -720,6 +957,9 @@ class IntakeService { sourcePreSkuId: preSku.id, sourceAssetVariantId: primarySourceAssetVariantId, sourceAssetVariantIds: sourceInfo.variantIds, + assetProfile: assetSkuSnapshot + ? { create: buildProductSkuAssetProfileData(assetSkuSnapshot) } + : undefined, warehouseLocationId: skuItem.warehouseLocationId ?? null, }, }) @@ -732,12 +972,14 @@ class IntakeService { product.id, createdSku.id, preSku.variantSelection, + sourceVariantById, ) await createHardwareFromSelections( tx, product.id, createdSku.id, preSku.variantSelection, + sourceVariantById, ) // 创建正式的包装方案 diff --git a/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts b/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts index e19ced3b..549707e8 100644 --- a/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts +++ b/furniture-3d-system/src/server/services/jst-sku-stock-sync.service.ts @@ -9,6 +9,10 @@ import { type NonIntegratedProductRecord, type NonIntegratedSkuItem, } from '@/app/intake/non-integrated/nonIntegratedStorage' +import { + buildStructuredProductProfileInput, + buildStructuredSkuItemInputs, +} from './intake-non-integrated-product.service' type JstSkuSourceField = | 'sku_id' @@ -108,7 +112,7 @@ function normalizeSourceString(value: unknown) { } function normalizeSkuId(value: unknown) { - return normalizeSourceString(value) + return normalizeSourceString(value).toUpperCase() } function buildStableId(prefix: string, value: string) { @@ -170,40 +174,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function readPayloadString(payload: unknown, key: string) { - if (!isRecord(payload)) return '' - return typeof payload[key] === 'string' ? payload[key].trim() : '' -} - -function readNestedPayloadString(payload: unknown, key: string) { - if (!isRecord(payload)) return '' - const metadata = payload.metadata - if (!isRecord(metadata)) return '' - return typeof metadata[key] === 'string' ? metadata[key].trim() : '' -} - -function isJstSkuPayload(payload: unknown) { - return [ - readPayloadString(payload, 'source'), - readPayloadString(payload, 'sourceTable'), - readPayloadString(payload, 'sourceType'), - readPayloadString(payload, 'importSource'), - readPayloadString(payload, 'externalSource'), - readNestedPayloadString(payload, 'source'), - readNestedPayloadString(payload, 'sourceTable'), - readNestedPayloadString(payload, 'sourceType'), - readNestedPayloadString(payload, 'importSource'), - readNestedPayloadString(payload, 'externalSource'), - ].some((value) => { - const normalizedValue = value.toLowerCase().replace(/-/g, '_') - return ( - normalizedValue === 'jst_sku' || - normalizedValue === 'jstsku' || - normalizedValue === 'jst_sku_stock_source' - ) - }) -} - function isJstSkuSkuItem(item: NonIntegratedSkuItem) { return ( item.id.trim().startsWith('jst-stock-sku-') || @@ -215,6 +185,13 @@ function isJstSkuSkuItem(item: NonIntegratedSkuItem) { ) } +function isJstStockProductIdentity(record: { + id: string + profile?: { sourceSpu?: string | null } | null +}) { + return record.id.startsWith('jst-stock-') || normalizeSourceString(record.profile?.sourceSpu).length > 0 +} + function mergeDefinedSourceFields>( target: T, source: Partial, @@ -276,7 +253,7 @@ function buildSourceSkuItem(row: JstSkuSourceRow, sequence: number): NonIntegrat } function readSourceSpuKey(row: JstSkuSourceRow) { - return normalizeSourceString(row.i_id) || normalizeSkuId(row.sku_id) + return normalizeSourceString(row.i_id).toUpperCase() || normalizeSkuId(row.sku_id) } function firstSourceValue(rows: JstSkuSourceRow[], key: JstSkuSourceField) { @@ -341,7 +318,7 @@ function buildSourceProductPayload( group: JstSkuSourceGroup, syncedAt: string, ): (NonIntegratedProductRecord & Record) | null { - const sourceSpu = group.sourceSpu.trim() + const sourceSpu = group.sourceSpu.trim().toUpperCase() if (!sourceSpu) return null const skuContentRows = pickSkuContentRows(group.rows) @@ -760,6 +737,7 @@ async function fetchSourceGroups(params: { async function findExistingStockProduct(id: string, spu: string, sourceSpu: string) { const existingById = await prisma.intakeNonIntegratedProduct.findUnique({ where: { id }, + include: { profile: true }, }) if (existingById) return existingById @@ -768,13 +746,15 @@ async function findExistingStockProduct(id: string, spu: string, sourceSpu: stri OR: [ { spu }, { - payload: { - path: ['metadata', 'sourceSpu'], - equals: sourceSpu, + profile: { + is: { + sourceSpu, + }, }, }, ], }, + include: { profile: true }, orderBy: { updatedAt: 'desc', }, @@ -782,7 +762,7 @@ async function findExistingStockProduct(id: string, spu: string, sourceSpu: stri } async function removeExcludedStockProduct(group: JstSkuSourceGroup, apply: boolean) { - const sourceSpu = group.sourceSpu.trim() + const sourceSpu = group.sourceSpu.trim().toUpperCase() if (!sourceSpu) return 'skipped' as const const id = buildStockProductId(sourceSpu) @@ -790,7 +770,7 @@ async function removeExcludedStockProduct(group: JstSkuSourceGroup, apply: boole const existing = await findExistingStockProduct(id, spu, sourceSpu) if (!existing) return 'skipped' as const - if (!isJstSkuPayload(existing.payload) && !existing.id.startsWith('jst-stock-')) { + if (!isJstStockProductIdentity(existing)) { return 'conflicts' as const } if (existing.deletedAt !== null) return 'unchanged' as const @@ -801,6 +781,9 @@ async function removeExcludedStockProduct(group: JstSkuSourceGroup, apply: boole data: { deletedAt: new Date(), updatedBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID, + skuItems: { + deleteMany: {}, + }, }, }) } @@ -821,15 +804,16 @@ async function syncOneSourceGroup(group: JstSkuSourceGroup, apply: boolean, sync const existing = await findExistingStockProduct( sourcePayload.id, sourcePayload.spu, - group.sourceSpu, + sourcePayload.spu, ) - if (existing && existing.id !== sourcePayload.id && !isJstSkuPayload(existing.payload)) { + if (existing && existing.id !== sourcePayload.id && !isJstStockProductIdentity(existing)) { return 'conflicts' as const } if (!existing) { if (apply) { + const structuredSkuItems = buildStructuredSkuItemInputs(sourcePayload) await prisma.intakeNonIntegratedProduct.create({ data: { id: sourcePayload.id, @@ -844,6 +828,18 @@ async function syncOneSourceGroup(group: JstSkuSourceGroup, apply: boolean, sync createdBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID, updatedBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID, createdAt: new Date(syncedAt), + profile: { + create: buildStructuredProductProfileInput(sourcePayload), + }, + ...(structuredSkuItems.length > 0 + ? { + skuItems: { + createMany: { + data: structuredSkuItems, + }, + }, + } + : {}), }, }) } @@ -862,6 +858,7 @@ async function syncOneSourceGroup(group: JstSkuSourceGroup, apply: boolean, sync } if (apply) { + const structuredSkuItems = buildStructuredSkuItemInputs(nextPayload) await prisma.intakeNonIntegratedProduct.update({ where: { id: existing.id }, data: { @@ -875,6 +872,22 @@ async function syncOneSourceGroup(group: JstSkuSourceGroup, apply: boolean, sync visibility: 'PUBLIC', updatedBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID, deletedAt: null, + profile: { + upsert: { + create: buildStructuredProductProfileInput(nextPayload), + update: buildStructuredProductProfileInput(nextPayload), + }, + }, + skuItems: { + deleteMany: {}, + ...(structuredSkuItems.length > 0 + ? { + createMany: { + data: structuredSkuItems, + }, + } + : {}), + }, }, }) } diff --git a/furniture-3d-system/src/server/services/logger.service.ts b/furniture-3d-system/src/server/services/logger.service.ts index 0b68c64e..916c3f72 100644 --- a/furniture-3d-system/src/server/services/logger.service.ts +++ b/furniture-3d-system/src/server/services/logger.service.ts @@ -22,22 +22,38 @@ export interface LogActivityParams { metadata?: LogData; } +function normalizeRequiredText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized ? normalized : null; +} + /** * 记录操作日志 */ export async function logActivity(params: LogActivityParams): Promise { try { + const userId = normalizeRequiredText(params.userId); + const action = normalizeRequiredText(params.action); + if (!userId || !action) { + throw new Error('操作日志缺少用户或动作'); + } + const targetType = normalizeRequiredText(params.targetType); + const targetId = normalizeRequiredText(params.targetId); + if (Boolean(targetType) !== Boolean(targetId)) { + throw new Error('操作日志目标类型和目标 ID 必须同时填写'); + } // 过滤敏感信息 const sanitizedChanges = sanitizeData(params.changes); const sanitizedMetadata = sanitizeData(params.metadata); await prisma.activityLog.create({ data: { - userId: params.userId, + userId, type: params.type, - action: params.action, - targetType: params.targetType, - targetId: params.targetId, + action, + targetType, + targetId, changes: sanitizedChanges, metadata: sanitizedMetadata, }, diff --git a/furniture-3d-system/src/server/services/material.service.ts b/furniture-3d-system/src/server/services/material.service.ts index 9010d3ad..ee6826a0 100644 --- a/furniture-3d-system/src/server/services/material.service.ts +++ b/furniture-3d-system/src/server/services/material.service.ts @@ -127,6 +127,33 @@ export interface MaterialListOptions { includeInactive?: boolean } +function normalizeMaterialCode(value: string | null | undefined): string { + return value?.trim().toUpperCase() ?? '' +} + +function normalizeRequiredText(value: string | null | undefined, fieldName: string): string { + const next = value?.trim() ?? '' + if (!next) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldName}不能为空`) + } + return next +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + return value?.trim() || null +} + +function normalizeNonNegativeInteger( + value: number | null | undefined, + fieldName: string, +): number | undefined { + if (value === undefined || value === null) return undefined + if (!Number.isInteger(value) || value < 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须是非负整数`) + } + return value +} + // ============ 材质服务类 ============ class MaterialService { @@ -269,18 +296,25 @@ class MaterialService { throw new AppError(ErrorCode.FILE_NOT_FOUND, '材质分类不存在') } + const name = normalizeRequiredText(input.name, '材质名称') + // 检查名称是否重复 const nameExists = await materialRepository.nameExistsInCategory( - input.name, + name, input.categoryId ) if (nameExists) { throw new AppError(ErrorCode.VALIDATION_FAILED, '该分类下已存在同名材质') } + const code = normalizeMaterialCode(input.code) || await this.buildNextMaterialCode() + if (await materialRepository.findByCode(code)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '材质编码已存在') + } + const createData = { - code: input.code?.trim() || await this.buildNextMaterialCode(), - name: input.name, + code, + name, category: { connect: { id: input.categoryId } }, thumbnailUrl: this.normalizeIncomingThumbnailUrl(input.thumbnailUrl), textureUrl: input.textureUrl, @@ -321,11 +355,13 @@ class MaterialService { } } + const nextName = input.name === undefined ? undefined : normalizeRequiredText(input.name, '材质名称') + // 如果更改名称,检查是否重复 - if (input.name && input.name !== existing.name) { + if (nextName !== undefined && nextName !== existing.name) { const targetCategoryId = input.categoryId || existing.categoryId const nameExists = await materialRepository.nameExistsInCategory( - input.name, + nextName, targetCategoryId, id ) @@ -337,7 +373,7 @@ class MaterialService { // 构建更新数据 const updateData: Record = {} - if (input.name !== undefined) updateData.name = input.name + if (nextName !== undefined) updateData.name = nextName if (input.categoryId !== undefined) { updateData.category = { connect: { id: input.categoryId } } } @@ -475,19 +511,22 @@ class MaterialService { * 创建分类 */ async createCategory(input: CreateMaterialCategoryInput): Promise { + const name = normalizeRequiredText(input.name, '分类名称') + const requestedSortOrder = normalizeNonNegativeInteger(input.sortOrder, '分类排序') + // 检查名称是否重复 - const nameExists = await materialCategoryRepository.nameExists(input.name) + const nameExists = await materialCategoryRepository.nameExists(name) if (nameExists) { throw new AppError(ErrorCode.VALIDATION_FAILED, '分类名称已存在') } // 获取下一个排序号 - const sortOrder = input.sortOrder ?? await materialCategoryRepository.getNextSortOrder() + const sortOrder = requestedSortOrder ?? await materialCategoryRepository.getNextSortOrder() // 创建分类 const category = await materialCategoryRepository.create({ - name: input.name, - icon: input.icon, + name, + icon: normalizeOptionalText(input.icon), sortOrder, isActive: input.isActive ?? true, }) @@ -512,9 +551,12 @@ class MaterialService { throw new AppError(ErrorCode.FILE_NOT_FOUND, '材质分类不存在') } + const nextName = input.name === undefined ? undefined : normalizeRequiredText(input.name, '分类名称') + const nextSortOrder = normalizeNonNegativeInteger(input.sortOrder, '分类排序') + // 如果更改名称,检查是否重复 - if (input.name && input.name !== existing.name) { - const nameExists = await materialCategoryRepository.nameExists(input.name, id) + if (nextName !== undefined && nextName !== existing.name) { + const nameExists = await materialCategoryRepository.nameExists(nextName, id) if (nameExists) { throw new AppError(ErrorCode.VALIDATION_FAILED, '分类名称已存在') } @@ -522,9 +564,9 @@ class MaterialService { // 更新分类 const category = await materialCategoryRepository.update(id, { - name: input.name, - icon: input.icon, - sortOrder: input.sortOrder, + name: nextName, + icon: input.icon === undefined ? undefined : normalizeOptionalText(input.icon), + sortOrder: nextSortOrder, isActive: input.isActive, }) diff --git a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts index d4b9b0a1..54fc7eea 100644 --- a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts +++ b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts @@ -6,6 +6,7 @@ import { STANDARD_FINISH_CATALOG, } from '@/lib/standard-finish-catalog' import { MATERIAL_CODE_PREFIX, formatMaterialCode, parseMaterialCode } from '@/lib/material-code' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { buildStandardFinishMaterialData } from '@/lib/standard-finish-sync' import type { OpenProductMountPoint } from '@/lib/open-product/types' import { AppError, ErrorCode } from '@/server/core/errors' @@ -70,7 +71,7 @@ async function buildNextSeedMaterialCode(): Promise { let sequence = (parseMaterialCode(latestMaterial?.code) ?? 0) + 1 let code = formatMaterialCode(sequence) - while (await prisma.material.findUnique({ where: { code } })) { + while (await prisma.material.findUnique({ where: { code: normalizeCoreIdentity(code) } })) { sequence += 1 code = formatMaterialCode(sequence) } diff --git a/furniture-3d-system/src/server/services/packaging-plan-calculator.ts b/furniture-3d-system/src/server/services/packaging-plan-calculator.ts index 0b570841..63973198 100644 --- a/furniture-3d-system/src/server/services/packaging-plan-calculator.ts +++ b/furniture-3d-system/src/server/services/packaging-plan-calculator.ts @@ -29,6 +29,24 @@ function toFiniteNumber(value: number | null | undefined): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } +function assertPositiveInteger(value: unknown, message: string): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, message) + } +} + +function assertPositiveNumber(value: unknown, message: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, message) + } +} + +function assertNonNegativeNumber(value: unknown, message: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, message) + } +} + function buildPackagingInputItemWeightMap(packagingInput: PreSkuPackagingInputDTO) { return new Map( packagingInput.items.map((item) => [ @@ -44,6 +62,51 @@ function buildMaterialVariantMap(materialVariants: AssetVariantWithRelations[]) return new Map(materialVariants.map((variant) => [variant.id, variant])) } +export function assertPackagingBoxesWritable(boxes: SavePreSkuPackagingPlanBoxInput[]): void { + const seenBoxNumbers = new Set() + + boxes.forEach((box, index) => { + const label = `第 ${index + 1} 个包装箱` + assertPositiveInteger(box.boxNumber, `${label}编号必须是正整数`) + if (seenBoxNumbers.has(box.boxNumber)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `包装箱编号 ${box.boxNumber} 重复`) + } + seenBoxNumbers.add(box.boxNumber) + + if (!box.name.trim()) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}名称不能为空`) + } + + assertPositiveNumber(box.outerSizeMm.length, `${label}长度必须大于 0`) + assertPositiveNumber(box.outerSizeMm.width, `${label}宽度必须大于 0`) + assertPositiveNumber(box.outerSizeMm.height, `${label}高度必须大于 0`) + + box.items.forEach((item, itemIndex) => { + assertPositiveNumber(item.quantity, `${label}第 ${itemIndex + 1} 个内装物数量必须大于 0`) + if (!item.refId.trim()) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}第 ${itemIndex + 1} 个内装物缺少来源 ID`) + } + if (!item.name.trim()) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}第 ${itemIndex + 1} 个内装物名称不能为空`) + } + }) + + box.materials.forEach((material, materialIndex) => { + assertPositiveNumber(material.quantity, `${label}第 ${materialIndex + 1} 个包装材料数量必须大于 0`) + if (!material.assetVariantId.trim()) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}第 ${materialIndex + 1} 个包装材料缺少规格款 ID`) + } + }) + + if (box.estimatedWeightKg !== undefined && box.estimatedWeightKg !== null) { + assertNonNegativeNumber(box.estimatedWeightKg, `${label}预估重量不能为负数`) + } + if (box.estimatedCost !== undefined && box.estimatedCost !== null) { + assertNonNegativeNumber(box.estimatedCost, `${label}预估成本不能为负数`) + } + }) +} + function assertPackagingMaterialVariant( materialVariantId: string, variantsById: Map, @@ -111,6 +174,8 @@ export function materializePackagingPlanForPersistence(input: { boxes: PreSkuPackagingPlanBoxDTO[] costSummary: PreSkuPackagingCostSummaryDTO | null } { + assertPackagingBoxesWritable(input.boxes) + if (input.boxes.length === 0) { return { boxes: [], diff --git a/furniture-3d-system/src/server/services/packaging.service.ts b/furniture-3d-system/src/server/services/packaging.service.ts index 0793f0cb..5ff3e1fc 100644 --- a/furniture-3d-system/src/server/services/packaging.service.ts +++ b/furniture-3d-system/src/server/services/packaging.service.ts @@ -74,6 +74,49 @@ export interface PackagingServiceResult { statusCode?: number } +function normalizeOptionalText(value: string | null | undefined): string | null { + return value?.trim() || null +} + +function validatePositiveInteger(value: number | null | undefined, fieldName: string): PackagingServiceResult | null { + if (value === undefined || value === null) return null + if (!Number.isInteger(value) || value <= 0) { + return { success: false, error: `${fieldName}必须是正整数`, code: ErrorCode.VALIDATION_FAILED, statusCode: 400 } + } + return null +} + +function validateNonNegativeNumber(value: number | null | undefined, fieldName: string): PackagingServiceResult | null { + if (value === undefined || value === null) return null + if (!Number.isFinite(value) || value < 0) { + return { success: false, error: `${fieldName}不能为负数`, code: ErrorCode.VALIDATION_FAILED, statusCode: 400 } + } + return null +} + +function validateBoxes(value: BoxData[] | undefined): PackagingServiceResult | null { + if (value === undefined) return null + if (!Array.isArray(value)) { + return { success: false, error: '箱子详情必须是数组', code: ErrorCode.VALIDATION_FAILED, statusCode: 400 } + } + for (const box of value) { + if ( + !Number.isInteger(box.boxNumber) || + !Number.isFinite(box.length) || + !Number.isFinite(box.width) || + !Number.isFinite(box.height) || + box.length <= 0 || + box.width <= 0 || + box.height <= 0 || + !Number.isFinite(box.weight) || + box.weight < 0 + ) { + return { success: false, error: '箱子编号、尺寸和重量不合法', code: ErrorCode.VALIDATION_FAILED, statusCode: 400 } + } + } + return null +} + // ============ 包装方案服务 ============ class PackagingService { @@ -129,6 +172,14 @@ class PackagingService { if (!input.name?.trim()) { return { success: false, error: '方案名称不能为空', code: ErrorCode.VALIDATION_REQUIRED_FIELD, statusCode: 400 } } + const boxValidation = validateBoxes(input.boxes) + if (boxValidation) return boxValidation + const boxCountValidation = validatePositiveInteger(input.boxCount, '箱数') + if (boxCountValidation) return boxCountValidation + const totalWeightValidation = validateNonNegativeNumber(input.totalWeight, '总重量') + if (totalWeightValidation) return totalWeightValidation + const totalVolumeValidation = validateNonNegativeNumber(input.totalVolume, '总体积') + if (totalVolumeValidation) return totalVolumeValidation // 如果设置为默认,先取消其他默认方案 if (input.isDefault) { @@ -143,12 +194,12 @@ class PackagingService { const plan = await packagingRepository.createPlan({ productId, name: input.name.trim(), - description: input.description?.trim() || null, + description: normalizeOptionalText(input.description), boxCount: input.boxCount || boxes.length || 1, boxes: boxes as unknown as object, totalWeight, totalVolume, - notes: input.notes?.trim() || null, + notes: normalizeOptionalText(input.notes), isDefault: input.isDefault || false, }) @@ -171,6 +222,17 @@ class PackagingService { if (!existingPlan) { return { success: false, error: '包装方案不存在', code: ErrorCode.FILE_NOT_FOUND, statusCode: 404 } } + if (input.name !== undefined && !input.name.trim()) { + return { success: false, error: '方案名称不能为空', code: ErrorCode.VALIDATION_REQUIRED_FIELD, statusCode: 400 } + } + const boxValidation = validateBoxes(input.boxes) + if (boxValidation) return boxValidation + const boxCountValidation = validatePositiveInteger(input.boxCount, '箱数') + if (boxCountValidation) return boxCountValidation + const totalWeightValidation = validateNonNegativeNumber(input.totalWeight, '总重量') + if (totalWeightValidation) return totalWeightValidation + const totalVolumeValidation = validateNonNegativeNumber(input.totalVolume, '总体积') + if (totalVolumeValidation) return totalVolumeValidation // 如果设置为默认,先取消其他默认方案 if (input.isDefault && !existingPlan.isDefault) { @@ -203,12 +265,12 @@ class PackagingService { const plan = await packagingRepository.updatePlan(input.id, { name: hasField('name') ? input.name?.trim() || existingPlan.name : existingPlan.name, - description: hasField('description') ? input.description?.trim() || null : existingPlan.description, + description: hasField('description') ? normalizeOptionalText(input.description) : existingPlan.description, boxCount: hasField('boxCount') ? input.boxCount ?? existingPlan.boxCount : (hasBoxes ? boxes.length || existingPlan.boxCount : existingPlan.boxCount), boxes: hasBoxes ? (boxes as unknown as object) : existingBoxes, totalWeight, totalVolume, - notes: hasField('notes') ? input.notes?.trim() || null : existingPlan.notes, + notes: hasField('notes') ? normalizeOptionalText(input.notes) : existingPlan.notes, isDefault: input.isDefault ?? existingPlan.isDefault, }) diff --git a/furniture-3d-system/src/server/services/panel-validation.service.ts b/furniture-3d-system/src/server/services/panel-validation.service.ts index a820d717..276de0ff 100644 --- a/furniture-3d-system/src/server/services/panel-validation.service.ts +++ b/furniture-3d-system/src/server/services/panel-validation.service.ts @@ -3,10 +3,89 @@ import { AppError, ErrorCode } from '@/server/core/errors' import { panelRepository } from '../repositories/panel.repository' import type { BatchAssemblySemanticUpdateItem, + CreatePanelInput, + ImportPanelItem, NormalizedInstallOrderUpdate, + UpdatePanelInput, } from './panel.service.types' +const PANEL_EXPLOSION_DIRECTIONS = new Set([ + 'auto', + 'up', + 'down', + 'left', + 'right', + 'front', + 'back', +]) + class PanelValidationService { + normalizeCreateInput(input: CreatePanelInput): CreatePanelInput { + return { + ...input, + code: this.normalizeCode(input.code), + name: this.normalizeRequiredText(input.name, '板件名称'), + gltfNodeName: this.normalizeRequiredText(input.gltfNodeName, 'glTF 节点名'), + partType: this.normalizeOptionalText(input.partType) ?? undefined, + length: this.normalizePositiveNumber(input.length, '板件长度'), + width: this.normalizePositiveNumber(input.width, '板件宽度'), + thickness: this.normalizePositiveNumber(input.thickness, '板件厚度'), + installOrder: + input.installOrder === undefined + ? undefined + : this.normalizeNonNegativeInteger(input.installOrder, '安装序号'), + explosionDirection: + this.normalizeExplosionDirection(input.explosionDirection) ?? undefined, + materialId: this.normalizeOptionalText(input.materialId) ?? undefined, + } + } + + normalizeUpdateInput(input: UpdatePanelInput): UpdatePanelInput { + return { + ...input, + ...(input.code !== undefined ? { code: this.normalizeCode(input.code) } : {}), + ...(input.name !== undefined + ? { name: this.normalizeRequiredText(input.name, '板件名称') } + : {}), + ...(input.gltfNodeName !== undefined + ? { gltfNodeName: this.normalizeRequiredText(input.gltfNodeName, 'glTF 节点名') } + : {}), + ...(input.partType !== undefined + ? { partType: this.normalizeOptionalText(input.partType) ?? undefined } + : {}), + ...(input.length !== undefined + ? { length: this.normalizePositiveNumber(input.length, '板件长度') } + : {}), + ...(input.width !== undefined + ? { width: this.normalizePositiveNumber(input.width, '板件宽度') } + : {}), + ...(input.thickness !== undefined + ? { thickness: this.normalizePositiveNumber(input.thickness, '板件厚度') } + : {}), + ...(input.installOrder !== undefined + ? { installOrder: this.normalizeNonNegativeInteger(input.installOrder, '安装序号') } + : {}), + ...(input.explosionDirection !== undefined + ? { explosionDirection: this.normalizeExplosionDirection(input.explosionDirection) ?? undefined } + : {}), + ...(input.materialId !== undefined + ? { materialId: input.materialId === null ? null : this.normalizeOptionalText(input.materialId) } + : {}), + } + } + + normalizeImportItems(panels: ImportPanelItem[]): ImportPanelItem[] { + const codeSet = new Set() + return panels.map((panel) => { + const normalized = this.normalizeCreateInput(panel) + if (codeSet.has(normalized.code)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `导入板件编号重复: ${normalized.code}`) + } + codeSet.add(normalized.code) + return normalized + }) + } + normalizeInstallOrderUpdates( updates: Array<{ id: string; installOrder: number }> ): NormalizedInstallOrderUpdate[] { @@ -173,6 +252,50 @@ class PanelValidationService { updates.forEach((item) => visit(item.panelId)) } + + private normalizeCode(value: string): string { + const code = value?.trim().toUpperCase() ?? '' + if (!code) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '板件编号不能为空') + } + return code + } + + private normalizeRequiredText(value: string, fieldName: string): string { + const normalized = value?.trim() ?? '' + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}不能为空`) + } + return normalized + } + + private normalizeOptionalText(value: string | undefined): string | null { + const normalized = value?.trim() ?? '' + return normalized || null + } + + private normalizePositiveNumber(value: number, fieldName: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须大于 0`) + } + return value + } + + private normalizeNonNegativeInteger(value: number, fieldName: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName}必须是非负整数`) + } + return value + } + + private normalizeExplosionDirection(value: string | undefined): string | null { + const normalized = value?.trim() ?? '' + if (!normalized) return null + if (!PANEL_EXPLOSION_DIRECTIONS.has(normalized)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '板件爆炸方向无效') + } + return normalized + } } export const panelValidationService = new PanelValidationService() diff --git a/furniture-3d-system/src/server/services/panel.service.ts b/furniture-3d-system/src/server/services/panel.service.ts index b8058961..f728fb28 100644 --- a/furniture-3d-system/src/server/services/panel.service.ts +++ b/furniture-3d-system/src/server/services/panel.service.ts @@ -183,28 +183,29 @@ class PanelServiceClass { async create(productId: string, input: CreatePanelInput, ctx: ServiceContext): Promise { const product = await panelScopeService.getProductForPermission(productId) panelScopeService.assertCanEditProduct(product, ctx.user, '您没有权限为此产品创建板件') + const normalizedInput = panelValidationService.normalizeCreateInput(input) - const codeExists = await panelRepository.codeExistsInProduct(productId, input.code, null) + const codeExists = await panelRepository.codeExistsInProduct(productId, normalizedInput.code, null) if (codeExists) { - throw new AppError(ErrorCode.VALIDATION_FAILED, `板件编号 "${input.code}" 已存在于该产品中`) + throw new AppError(ErrorCode.VALIDATION_FAILED, `板件编号 "${normalizedInput.code}" 已存在于该产品中`) } const panel = await panelRepository.createWithRelations({ productId, - code: input.code, - name: input.name, - gltfNodeName: input.gltfNodeName, - partType: input.partType || null, - length: input.length, - width: input.width, - thickness: input.thickness, - installOrder: input.installOrder || 0, - explosionDirection: input.explosionDirection || null, - isAnchor: input.isAnchor || false, + code: normalizedInput.code, + name: normalizedInput.name, + gltfNodeName: normalizedInput.gltfNodeName, + partType: normalizedInput.partType || null, + length: normalizedInput.length, + width: normalizedInput.width, + thickness: normalizedInput.thickness, + installOrder: normalizedInput.installOrder ?? 0, + explosionDirection: normalizedInput.explosionDirection || null, + isAnchor: normalizedInput.isAnchor || false, }) - if (input.materialId) { - await panelRepository.setMaterial(panel.id, input.materialId) + if (normalizedInput.materialId) { + await panelRepository.setMaterial(panel.id, normalizedInput.materialId) } await this.invalidateProductCache(productId) @@ -219,38 +220,39 @@ class PanelServiceClass { const product = await panelScopeService.getProductForPermission(existing.productId) panelScopeService.assertCanEditProduct(product, ctx.user, '您没有权限修改此板件') + const normalizedInput = panelValidationService.normalizeUpdateInput(input) - if (input.code && input.code !== existing.code) { + if (normalizedInput.code && normalizedInput.code !== existing.code) { const codeExists = await panelRepository.codeExistsInProduct( existing.productId, - input.code, + normalizedInput.code, existing.skuId ?? null, id ) if (codeExists) { - throw new AppError(ErrorCode.VALIDATION_FAILED, `板件编号 "${input.code}" 已存在于该产品中`) + throw new AppError(ErrorCode.VALIDATION_FAILED, `板件编号 "${normalizedInput.code}" 已存在于该产品中`) } } const updateData: Prisma.PanelUpdateInput = {} - if (input.code !== undefined) updateData.code = input.code - if (input.name !== undefined) updateData.name = input.name - if (input.gltfNodeName !== undefined) updateData.gltfNodeName = input.gltfNodeName - if (input.partType !== undefined) updateData.partType = input.partType - if (input.length !== undefined) updateData.length = input.length - if (input.width !== undefined) updateData.width = input.width - if (input.thickness !== undefined) updateData.thickness = input.thickness - if (input.installOrder !== undefined) updateData.installOrder = input.installOrder - if (input.explosionDirection !== undefined) updateData.explosionDirection = input.explosionDirection - if (input.isAnchor !== undefined) updateData.isAnchor = input.isAnchor + if (normalizedInput.code !== undefined) updateData.code = normalizedInput.code + if (normalizedInput.name !== undefined) updateData.name = normalizedInput.name + if (normalizedInput.gltfNodeName !== undefined) updateData.gltfNodeName = normalizedInput.gltfNodeName + if (input.partType !== undefined) updateData.partType = normalizedInput.partType ?? null + if (normalizedInput.length !== undefined) updateData.length = normalizedInput.length + if (normalizedInput.width !== undefined) updateData.width = normalizedInput.width + if (normalizedInput.thickness !== undefined) updateData.thickness = normalizedInput.thickness + if (normalizedInput.installOrder !== undefined) updateData.installOrder = normalizedInput.installOrder + if (input.explosionDirection !== undefined) updateData.explosionDirection = normalizedInput.explosionDirection ?? null + if (normalizedInput.isAnchor !== undefined) updateData.isAnchor = normalizedInput.isAnchor await panelRepository.update(id, updateData) - if (input.materialId !== undefined) { - if (input.materialId === null) { + if (normalizedInput.materialId !== undefined) { + if (normalizedInput.materialId === null) { await panelRepository.removeMaterial(id) } else { - await panelRepository.setMaterial(id, input.materialId) + await panelRepository.setMaterial(id, normalizedInput.materialId) } } @@ -295,6 +297,7 @@ class PanelServiceClass { const existingPanels = await panelRepository.findByProductId(productId) const existingByCode = new Map(existingPanels.map((panel) => [panel.code, panel])) + const normalizedPanels = panelValidationService.normalizeImportItems(panels) const upsertItems: Array<{ productId: string code: string @@ -309,7 +312,7 @@ class PanelServiceClass { isAnchor: boolean }> = [] - for (const panelData of panels) { + for (const panelData of normalizedPanels) { try { const existing = existingByCode.get(panelData.code) upsertItems.push({ diff --git a/furniture-3d-system/src/server/services/planning-validation.service.ts b/furniture-3d-system/src/server/services/planning-validation.service.ts index 6feafaae..2af46713 100644 --- a/furniture-3d-system/src/server/services/planning-validation.service.ts +++ b/furniture-3d-system/src/server/services/planning-validation.service.ts @@ -1,8 +1,19 @@ import type { PlanningSlotDefinition } from '@/lib/planning/candidate-generator' +import type { PlanningSlotSelections } from '@/lib/planning/candidate-generator' import { parsePlanningTemplateDefinition } from '@/lib/planning/parsers' import { AppError, ErrorCode } from '@/server/core/errors' import { categoryRepository } from '../repositories' +const PLANNING_VISIBILITIES = new Set(['PRIVATE', 'TEAM', 'DEPARTMENT', 'PUBLIC']) + +function hasControlCharacter(value: string): boolean { + return /[\u0000-\u001F\u007F]/.test(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + class PlanningValidationService { parseTemplateDefinition(input: { code: string @@ -21,6 +32,24 @@ class PlanningValidationService { throw new AppError(ErrorCode.VALIDATION_FAILED, '规划模板槽位定义不正确') } + const seenSlotKeys = new Set() + const definedSlotKeys = new Set() + for (const slot of parsedTemplate.slots) { + if (seenSlotKeys.has(slot.key)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `规划模板槽位 key 重复:${slot.key}`) + } + seenSlotKeys.add(slot.key) + + if (slot.placement.mode === 'attach' && !definedSlotKeys.has(slot.placement.parentSlotKey)) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `槽位“${slot.name}”引用的父槽位不存在或顺序靠后:${slot.placement.parentSlotKey}`, + ) + } + + definedSlotKeys.add(slot.key) + } + return parsedTemplate } @@ -32,6 +61,91 @@ class PlanningValidationService { throw new AppError(ErrorCode.CATEGORY_NOT_FOUND, '产品分类不存在') } } + + normalizeRequiredText(value: unknown, fieldLabel: string): string { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldLabel}不能为空`) + } + + const trimmed = value.trim() + if (!trimmed) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldLabel}不能为空`) + } + if (hasControlCharacter(trimmed)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}不能包含控制字符`) + } + + return trimmed + } + + normalizeOptionalText(value: unknown, fieldLabel: string): string | null { + if (value === undefined || value === null) return null + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}必须是文本`) + } + + const trimmed = value.trim() + if (!trimmed) return null + if (hasControlCharacter(trimmed)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}不能包含控制字符`) + } + + return trimmed + } + + normalizeOptionalObject(value: unknown, fieldLabel: string): Record | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + if (!isRecord(value)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldLabel}必须是对象`) + } + return value + } + + normalizeVisibility(value: unknown): string { + if (value === undefined || value === null || value === '') return 'TEAM' + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_FAILED, '规划工作台可见范围不正确') + } + + const visibility = value.trim().toUpperCase() + if (!PLANNING_VISIBILITIES.has(visibility)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '规划工作台可见范围不正确') + } + + return visibility + } + + normalizeSlotSelections( + value: PlanningSlotSelections | undefined, + templateSlots: PlanningSlotDefinition[], + ): PlanningSlotSelections { + if (value === undefined) return {} + if (!isRecord(value)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '规划工作台槽位选择必须是对象') + } + + const slotKeys = new Set(templateSlots.map((slot) => slot.key)) + const normalized: PlanningSlotSelections = {} + + Object.entries(value).forEach(([rawSlotKey, rawAssetIds]) => { + const slotKey = this.normalizeRequiredText(rawSlotKey, '槽位 key') + if (!slotKeys.has(slotKey)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `槽位选择引用了模板外槽位:${slotKey}`) + } + if (!Array.isArray(rawAssetIds)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `槽位“${slotKey}”的资产选择必须是数组`) + } + + normalized[slotKey] = Array.from( + new Set( + rawAssetIds.map((assetId) => this.normalizeRequiredText(assetId, '资产 ID')), + ), + ) + }) + + return normalized + } } export const planningValidationService = new PlanningValidationService() diff --git a/furniture-3d-system/src/server/services/planning.service.ts b/furniture-3d-system/src/server/services/planning.service.ts index 0128551a..bb2fcaca 100644 --- a/furniture-3d-system/src/server/services/planning.service.ts +++ b/furniture-3d-system/src/server/services/planning.service.ts @@ -1,5 +1,4 @@ import { Prisma } from '@prisma/client' -import { parsePlanningSlotSelections } from '@/lib/planning/parsers' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' import { @@ -18,7 +17,6 @@ import type { CreatePlanningWorkspaceInput, PlanningCandidateGenerationDTO, PlanningDraftDTO, - PlanningSlotAssetOption, PlanningTemplateDTO, PlanningWorkspaceDTO, PlanningWorkspaceDetailDTO, @@ -26,6 +24,10 @@ import type { UpdatePlanningWorkspaceInput, } from './planning.service.types' +function normalizePlanningCode(value: string): string { + return value.trim().toUpperCase() +} + class PlanningService { async listTemplates(): Promise { const records = await planningTemplateRepository.findAllWithRelations(true) @@ -47,16 +49,19 @@ class PlanningService { ): Promise { planningScopeService.assertCanWritePlanning(user, '当前账号没有权限创建规划模板') - const code = input.code.trim() - const name = input.name.trim() - if (!code || !name) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '模板编码和名称不能为空') + const code = normalizePlanningCode(input.code) + const name = planningValidationService.normalizeRequiredText(input.name, '模板名称') + if (!code) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '模板编码不能为空') } + const description = planningValidationService.normalizeOptionalText(input.description, '模板描述') + const namePattern = planningValidationService.normalizeOptionalText(input.namePattern, '模板命名规则') + const rules = planningValidationService.normalizeOptionalObject(input.rules, '模板规则') const parsedTemplate = planningValidationService.parseTemplateDefinition({ code, name, - namePattern: input.namePattern ?? null, + namePattern, slots: input.slots, }) @@ -69,10 +74,10 @@ class PlanningService { const created = await planningTemplateRepository.create({ code, name, - description: input.description?.trim() || null, - namePattern: input.namePattern?.trim() || null, + description, + namePattern, slots: parsedTemplate.slots as unknown as Prisma.InputJsonValue, - rules: toNullableJsonInput(input.rules), + rules: toNullableJsonInput(rules), isPreset: input.isPreset ?? false, isActive: input.isActive ?? true, category: input.categoryId ? { connect: { id: input.categoryId } } : undefined, @@ -99,11 +104,27 @@ class PlanningService { throw new AppError(ErrorCode.PLANNING_TEMPLATE_NOT_FOUND, '规划模板不存在') } - const nextCode = input.code?.trim() ?? existing.code - const nextName = input.name?.trim() ?? existing.name + const nextCode = input.code === undefined ? existing.code : normalizePlanningCode(input.code) + if (!nextCode) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '模板编码不能为空') + } + const nextName = + input.name === undefined + ? existing.name + : planningValidationService.normalizeRequiredText(input.name, '模板名称') const nextNamePattern = - input.namePattern === undefined ? existing.namePattern : input.namePattern?.trim() || null + input.namePattern === undefined + ? existing.namePattern + : planningValidationService.normalizeOptionalText(input.namePattern, '模板命名规则') const nextSlots = input.slots ?? toTemplateDefinition(existing).slots + const nextDescription = + input.description === undefined + ? undefined + : planningValidationService.normalizeOptionalText(input.description, '模板描述') + const nextRules = + input.rules === undefined + ? undefined + : planningValidationService.normalizeOptionalObject(input.rules, '模板规则') const parsedTemplate = planningValidationService.parseTemplateDefinition({ code: nextCode, @@ -124,10 +145,10 @@ class PlanningService { await planningTemplateRepository.update(id, { code: input.code === undefined ? undefined : nextCode, name: input.name === undefined ? undefined : nextName, - description: input.description === undefined ? undefined : input.description?.trim() || null, + description: nextDescription, namePattern: input.namePattern === undefined ? undefined : nextNamePattern, slots: input.slots === undefined ? undefined : (parsedTemplate.slots as unknown as Prisma.InputJsonValue), - rules: input.rules === undefined ? undefined : toNullableJsonInput(input.rules), + rules: input.rules === undefined ? undefined : toNullableJsonInput(nextRules), isPreset: input.isPreset, isActive: input.isActive, category: @@ -170,12 +191,21 @@ class PlanningService { await planningValidationService.assertCategoryExists(categoryId) const departmentId = planningScopeService.getScopedDepartmentId(user) + const name = planningValidationService.normalizeRequiredText(input.name, '规划工作台名称') + const description = planningValidationService.normalizeOptionalText(input.description, '规划工作台描述') + const visibility = planningValidationService.normalizeVisibility(input.visibility) + const templateDefinition = toTemplateDefinition(template) + const slotSelections = planningValidationService.normalizeSlotSelections( + input.slotSelections, + templateDefinition.slots, + ) + const metadata = planningValidationService.normalizeOptionalObject(input.metadata, '规划工作台元数据') const created = await planningWorkspaceRepository.create({ - name: input.name.trim(), - description: input.description?.trim() || null, - visibility: input.visibility ?? 'TEAM', - slotSelections: parsePlanningSlotSelections(input.slotSelections) as unknown as Prisma.InputJsonValue, - metadata: toNullableJsonInput(input.metadata), + name, + description, + visibility, + slotSelections: slotSelections as unknown as Prisma.InputJsonValue, + metadata: toNullableJsonInput(metadata), template: { connect: { id: input.templateId } }, category: categoryId ? { connect: { id: categoryId } } : undefined, creator: { connect: { id: user.id } }, @@ -205,15 +235,43 @@ class PlanningService { await planningValidationService.assertCategoryExists(input.categoryId) + const template = + input.slotSelections === undefined + ? null + : await planningTemplateRepository.findByIdWithRelations(workspace.templateId) + if (input.slotSelections !== undefined && !template) { + throw new AppError(ErrorCode.PLANNING_TEMPLATE_NOT_FOUND, '规划模板不存在') + } + const nextSlotSelections = + input.slotSelections === undefined || !template + ? undefined + : planningValidationService.normalizeSlotSelections( + input.slotSelections, + toTemplateDefinition(template).slots, + ) + const nextMetadata = + input.metadata === undefined + ? undefined + : planningValidationService.normalizeOptionalObject(input.metadata, '规划工作台元数据') + const updated = await planningWorkspaceRepository.updateWithRelations(id, { - name: input.name === undefined ? undefined : input.name.trim(), - description: input.description === undefined ? undefined : input.description?.trim() || null, - visibility: input.visibility, + name: + input.name === undefined + ? undefined + : planningValidationService.normalizeRequiredText(input.name, '规划工作台名称'), + description: + input.description === undefined + ? undefined + : planningValidationService.normalizeOptionalText(input.description, '规划工作台描述'), + visibility: + input.visibility === undefined + ? undefined + : planningValidationService.normalizeVisibility(input.visibility), slotSelections: input.slotSelections === undefined ? undefined - : (parsePlanningSlotSelections(input.slotSelections) as unknown as Prisma.InputJsonValue), - metadata: input.metadata === undefined ? undefined : toNullableJsonInput(input.metadata), + : (nextSlotSelections as unknown as Prisma.InputJsonValue), + metadata: input.metadata === undefined ? undefined : toNullableJsonInput(nextMetadata), category: input.categoryId === undefined ? undefined @@ -280,13 +338,15 @@ class PlanningService { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有权限保存该工作台草稿') } - const name = input.name.trim() - if (!name) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '草稿名称不能为空') - } + const name = planningValidationService.normalizeRequiredText(input.name, '草稿名称') + const selectedCandidateKey = planningValidationService.normalizeRequiredText( + input.selectedCandidateKey, + '候选组合 key', + ) + const notes = planningValidationService.normalizeOptionalText(input.notes, '草稿备注') const candidate = generationContext.candidates.find( - (item) => item.key === input.selectedCandidateKey, + (item) => item.key === selectedCandidateKey, ) if (!candidate) { throw new AppError(ErrorCode.VALIDATION_FAILED, '所选候选组合不存在,请先重新生成候选') @@ -302,7 +362,7 @@ class PlanningService { previewNodes: candidate.previewNodes as unknown as Prisma.InputJsonValue, selectedAssets: selectedAssets as unknown as Prisma.InputJsonValue, warnings: candidate.warnings as unknown as Prisma.InputJsonValue, - notes: input.notes?.trim() || null, + notes, sourceSnapshot: { workspaceId: generationContext.workspace.id, templateId: generationContext.template.id, diff --git a/furniture-3d-system/src/server/services/pre-sku-packaging.service.ts b/furniture-3d-system/src/server/services/pre-sku-packaging.service.ts index 0ba63c65..b623fb07 100644 --- a/furniture-3d-system/src/server/services/pre-sku-packaging.service.ts +++ b/furniture-3d-system/src/server/services/pre-sku-packaging.service.ts @@ -20,11 +20,25 @@ import type { SavePreSkuPackagingPlanInput, } from './draft-group.service.types' +const PRE_SKU_PACKAGING_PLAN_STATUSES = new Set(['DRAFT', 'CONFIRMED', 'ARCHIVED']) + function toInputJsonOrNull(value: unknown) { if (value === undefined || value === null) return Prisma.JsonNull return value as Prisma.InputJsonValue } +function normalizePackagingPlanStatus(value: string | null | undefined): string { + const status = value?.trim() || 'DRAFT' + if (!PRE_SKU_PACKAGING_PLAN_STATUSES.has(status)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '包装方案状态不在允许范围内') + } + return status +} + +function normalizePackagingSourceRefId(value: string | null | undefined): string | null { + return value?.trim() || null +} + async function buildNormalizedPackagingDraft( preSku: PreSkuWithRelations, input: SavePreSkuPackagingPlanInput, @@ -107,8 +121,8 @@ class PreSkuPackagingService { packagingInput, packagingPlan: { sourceType: input.sourceType ?? 'PRE_SKU', - sourceRefId: input.sourceRefId ?? null, - status: input.status ?? 'DRAFT', + sourceRefId: normalizePackagingSourceRefId(input.sourceRefId), + status: normalizePackagingPlanStatus(input.status), boxes: normalizedPackaging.boxes, costSummary: normalizedPackaging.costSummary, processDraft: input.processDraft ?? null, @@ -137,8 +151,8 @@ class PreSkuPackagingService { const saved = await preSkuPackagingPlanRepository.upsertByPreSkuId(preSkuId, { preSkuId, sourceType: input.sourceType ?? 'PRE_SKU', - sourceRefId: input.sourceRefId ?? null, - status: input.status ?? 'DRAFT', + sourceRefId: normalizePackagingSourceRefId(input.sourceRefId), + status: normalizePackagingPlanStatus(input.status), boxes: normalizedPackaging.boxes as unknown as Prisma.InputJsonValue, costSummary: normalizedPackaging.costSummary === null diff --git a/furniture-3d-system/src/server/services/product-cad-upload.service.ts b/furniture-3d-system/src/server/services/product-cad-upload.service.ts index d9ffe9d2..e2aaf44f 100644 --- a/furniture-3d-system/src/server/services/product-cad-upload.service.ts +++ b/furniture-3d-system/src/server/services/product-cad-upload.service.ts @@ -14,6 +14,7 @@ import { processCADExport, validateCADExportData } from '@/lib/cad-export-proces import { AppError, ErrorCode } from '@/server/core/errors' const MAX_FILE_SIZE = 200 * 1024 * 1024 +const CAD_UPLOAD_VISIBILITIES = new Set(['TEAM', 'DEPARTMENT', 'PUBLIC']) export interface PreparedCADProductPayload { modelNumber: string @@ -33,7 +34,7 @@ class ProductCADUploadService { const name = (formData.get('name') as string) || '' const category = (formData.get('category') as string) || undefined const description = (formData.get('description') as string) || undefined - const visibility = ((formData.get('visibility') as string) || 'TEAM') as 'TEAM' | 'DEPARTMENT' | 'PUBLIC' + const visibility = this.normalizeVisibility(formData.get('visibility') as string) if (!modelNumber.trim() || !name.trim()) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '产品型号和名称为必填项') @@ -116,6 +117,14 @@ class ProductCADUploadService { processingResult, } } + + private normalizeVisibility(value: string | undefined): 'TEAM' | 'DEPARTMENT' | 'PUBLIC' { + const visibility = (value || 'TEAM').trim().toUpperCase() + if (!CAD_UPLOAD_VISIBILITIES.has(visibility)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '产品可见范围无效') + } + return visibility as 'TEAM' | 'DEPARTMENT' | 'PUBLIC' + } } export const productCADUploadService = new ProductCADUploadService() diff --git a/furniture-3d-system/src/server/services/product-collaborator.service.ts b/furniture-3d-system/src/server/services/product-collaborator.service.ts index d232897b..5c4d322b 100644 --- a/furniture-3d-system/src/server/services/product-collaborator.service.ts +++ b/furniture-3d-system/src/server/services/product-collaborator.service.ts @@ -8,6 +8,7 @@ import { productRepository } from '../repositories/product.repository' import { userRepository } from '../repositories/user.repository' type CollaboratorRole = 'editor' | 'viewer' +const COLLABORATOR_ROLES = new Set(['editor', 'viewer']) interface CollaboratorRecord { userId: string @@ -91,31 +92,26 @@ class ProductCollaboratorService { input: AddCollaboratorInput, currentUser: AuthUser ) { - if (!input.userId) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少必要参数:userId') - } + const normalizedProductId = this.normalizeRequiredId(productId, 'productId') + const normalizedUserId = this.normalizeRequiredId(input.userId, 'userId') + const role = this.normalizeCollaboratorRole(input.role) - const role: CollaboratorRole = input.role ?? 'editor' - if (!['editor', 'viewer'].includes(role)) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '无效的角色类型') - } - - const product = await this.getProductOrThrow(productId) + const product = await this.getProductOrThrow(normalizedProductId) this.assertCanManage(product, currentUser) - const targetUser = await userRepository.findCollaboratorById(input.userId) + const targetUser = await userRepository.findCollaboratorById(normalizedUserId) if (!targetUser) { throw new AppError(ErrorCode.USER_NOT_FOUND, '目标用户不存在') } const collaborators = this.normalizeCollaborators(product) - const exists = collaborators.some((collaborator) => collaborator.userId === input.userId) + const exists = collaborators.some((collaborator) => collaborator.userId === normalizedUserId) if (exists) { throw new AppError(ErrorCode.VALIDATION_FAILED, '该用户已是协作者') } const newCollaborator: CollaboratorRecord = { - userId: input.userId, + userId: normalizedUserId, role, addedAt: new Date().toISOString(), addedBy: currentUser.id, @@ -124,7 +120,7 @@ class ProductCollaboratorService { await prisma.$transaction(async (tx) => { await productRepository.addCollaboratorRelation( product.id, - input.userId, + normalizedUserId, role, currentUser.id, tx @@ -157,26 +153,25 @@ class ProductCollaboratorService { } async removeCollaborator(productId: string, collaboratorUserId: string, currentUser: AuthUser) { - if (!collaboratorUserId) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少必要参数:userId') - } + const normalizedProductId = this.normalizeRequiredId(productId, 'productId') + const normalizedUserId = this.normalizeRequiredId(collaboratorUserId, 'userId') - const product = await this.getProductOrThrow(productId) + const product = await this.getProductOrThrow(normalizedProductId) this.assertCanManage(product, currentUser) const collaborators = this.normalizeCollaborators(product) const updatedCollaborators = collaborators.filter( - (collaborator) => collaborator.userId !== collaboratorUserId + (collaborator) => collaborator.userId !== normalizedUserId ) if (updatedCollaborators.length === collaborators.length) { throw new AppError(ErrorCode.VALIDATION_FAILED, '该用户不是协作者') } - const removedUser = await userRepository.findCollaboratorById(collaboratorUserId) + const removedUser = await userRepository.findCollaboratorById(normalizedUserId) await prisma.$transaction(async (tx) => { - await productRepository.removeCollaboratorRelation(product.id, collaboratorUserId, tx) + await productRepository.removeCollaboratorRelation(product.id, normalizedUserId, tx) await logRepository.createLog( { @@ -184,9 +179,9 @@ class ProductCollaboratorService { type: ActivityType.PRODUCT_UPDATE, targetType: 'Product', targetId: product.id, - action: `移除协作者: ${removedUser?.name ?? collaboratorUserId}`, + action: `移除协作者: ${removedUser?.name ?? normalizedUserId}`, metadata: { - collaboratorId: collaboratorUserId, + collaboratorId: normalizedUserId, collaboratorName: removedUser?.name, }, }, @@ -245,12 +240,41 @@ class ProductCollaboratorService { } private normalizeCollaborators(product: ProductForCollaborators): CollaboratorRecord[] { - return product.collaboratorUsers.map((item) => ({ - userId: item.userId, - role: item.role === 'viewer' ? 'viewer' : 'editor', - addedAt: item.addedAt.toISOString(), - addedBy: item.addedBy ?? '', - })) + return product.collaboratorUsers + .map((item) => { + const userId = item.userId.trim() + const role = this.tryNormalizeCollaboratorRole(item.role) + if (!userId || !role) return null + + return { + userId, + role, + addedAt: item.addedAt.toISOString(), + addedBy: item.addedBy?.trim() ?? '', + } + }) + .filter((item): item is CollaboratorRecord => item !== null) + } + + private normalizeRequiredId(value: string | undefined, fieldName: string): string { + const normalized = value?.trim() ?? '' + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少必要参数:${fieldName}`) + } + return normalized + } + + private normalizeCollaboratorRole(value?: CollaboratorRole): CollaboratorRole { + const role = (value ?? 'editor').trim().toLowerCase() as CollaboratorRole + if (!COLLABORATOR_ROLES.has(role)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '无效的角色类型') + } + return role + } + + private tryNormalizeCollaboratorRole(value: string): CollaboratorRole | null { + const role = value.trim().toLowerCase() as CollaboratorRole + return COLLABORATOR_ROLES.has(role) ? role : null } } diff --git a/furniture-3d-system/src/server/services/product-hardware.service.ts b/furniture-3d-system/src/server/services/product-hardware.service.ts index 3124fc68..d0f7d50f 100644 --- a/furniture-3d-system/src/server/services/product-hardware.service.ts +++ b/furniture-3d-system/src/server/services/product-hardware.service.ts @@ -22,16 +22,19 @@ interface ServiceContext { } export interface UpdateProductHardwareInput { - hardwareId?: string + hardwareId?: string | null matchStatus?: 'matched' | 'pending' | 'unmatched' matchConfidence?: number - matchMethod?: 'blockName' | 'mapping' | 'size' | 'manual' + matchMethod?: 'blockName' | 'mapping' | 'size' | 'manual' | null panelId?: string | null connectedPanelId?: string | null installOrder?: number | null notes?: string | null } +const PRODUCT_HARDWARE_MATCH_STATUSES = new Set(['matched', 'pending', 'unmatched']) +const PRODUCT_HARDWARE_MATCH_METHODS = new Set(['blockName', 'mapping', 'size', 'manual']) + type ProductPermissionSnapshot = NonNullable< Awaited> > @@ -65,16 +68,17 @@ class ProductHardwareService { const product = await this.getProductForPermission(existing.productId) this.assertCanEditProduct(product, user, '您没有权限编辑此产品五金件') + const normalizedInput = this.normalizeUpdateInput(input) - if (input.hardwareId) { - const hardware = await hardwareRepository.findById(input.hardwareId) + if (normalizedInput.hardwareId) { + const hardware = await hardwareRepository.findById(normalizedInput.hardwareId) if (!hardware) { throw new AppError(ErrorCode.HARDWARE_NOT_FOUND, '五金件不存在') } } - if (input.panelId) { - const panel = await panelRepository.findById(input.panelId) + if (normalizedInput.panelId) { + const panel = await panelRepository.findById(normalizedInput.panelId) if (!panel) { throw new AppError(ErrorCode.PANEL_NOT_FOUND, '板件不存在') } @@ -83,8 +87,8 @@ class ProductHardwareService { } } - if (input.connectedPanelId) { - const connectedPanel = await panelRepository.findById(input.connectedPanelId) + if (normalizedInput.connectedPanelId) { + const connectedPanel = await panelRepository.findById(normalizedInput.connectedPanelId) if (!connectedPanel) { throw new AppError(ErrorCode.PANEL_NOT_FOUND, '连接板件不存在') } @@ -95,33 +99,33 @@ class ProductHardwareService { const updateData: Prisma.ProductHardwareUpdateInput = {} - if (input.hardwareId !== undefined) { - updateData.hardware = input.hardwareId - ? { connect: { id: input.hardwareId } } + if (normalizedInput.hardwareId !== undefined) { + updateData.hardware = normalizedInput.hardwareId + ? { connect: { id: normalizedInput.hardwareId } } : { disconnect: true } } - if (input.matchStatus !== undefined) { - updateData.matchStatus = input.matchStatus + if (normalizedInput.matchStatus !== undefined) { + updateData.matchStatus = normalizedInput.matchStatus } - if (input.matchConfidence !== undefined) { - updateData.matchConfidence = input.matchConfidence + if (normalizedInput.matchConfidence !== undefined) { + updateData.matchConfidence = normalizedInput.matchConfidence } - if (input.matchMethod !== undefined) { - updateData.matchMethod = input.matchMethod + if (normalizedInput.matchMethod !== undefined) { + updateData.matchMethod = normalizedInput.matchMethod } - if (input.panelId !== undefined) { - updateData.panel = input.panelId - ? { connect: { id: input.panelId } } + if (normalizedInput.panelId !== undefined) { + updateData.panel = normalizedInput.panelId + ? { connect: { id: normalizedInput.panelId } } : { disconnect: true } } - if (input.connectedPanelId !== undefined) { - updateData.connectedPanelId = input.connectedPanelId + if (normalizedInput.connectedPanelId !== undefined) { + updateData.connectedPanelId = normalizedInput.connectedPanelId } - if (input.installOrder !== undefined) { - updateData.installOrder = input.installOrder + if (normalizedInput.installOrder !== undefined) { + updateData.installOrder = normalizedInput.installOrder } - if (input.notes !== undefined) { - updateData.notes = input.notes + if (normalizedInput.notes !== undefined) { + updateData.notes = normalizedInput.notes } return productHardwareRepository.updateWithRelations(id, updateData) @@ -148,6 +152,58 @@ class ProductHardwareService { return ctx.user } + private normalizeUpdateInput(input: UpdateProductHardwareInput): UpdateProductHardwareInput { + return { + ...input, + ...(input.hardwareId !== undefined ? { hardwareId: this.normalizeOptionalText(input.hardwareId) } : {}), + ...(input.matchStatus !== undefined ? { matchStatus: this.normalizeMatchStatus(input.matchStatus) } : {}), + ...(input.matchConfidence !== undefined ? { matchConfidence: this.normalizeConfidence(input.matchConfidence) } : {}), + ...(input.matchMethod !== undefined ? { matchMethod: this.normalizeMatchMethod(input.matchMethod) } : {}), + ...(input.panelId !== undefined ? { panelId: this.normalizeOptionalText(input.panelId) } : {}), + ...(input.connectedPanelId !== undefined ? { connectedPanelId: this.normalizeOptionalText(input.connectedPanelId) } : {}), + ...(input.installOrder !== undefined ? { installOrder: this.normalizeInstallOrder(input.installOrder) } : {}), + ...(input.notes !== undefined ? { notes: this.normalizeOptionalText(input.notes) } : {}), + } + } + + private normalizeMatchStatus(value: string): 'matched' | 'pending' | 'unmatched' { + const normalized = value.trim() as 'matched' | 'pending' | 'unmatched' + if (!PRODUCT_HARDWARE_MATCH_STATUSES.has(normalized)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '五金匹配状态无效') + } + return normalized + } + + private normalizeMatchMethod(value: string | null | undefined): 'blockName' | 'mapping' | 'size' | 'manual' | null { + const normalized = value?.trim() ?? '' + if (!normalized) return null + if (!PRODUCT_HARDWARE_MATCH_METHODS.has(normalized)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '五金匹配方式无效') + } + return normalized as 'blockName' | 'mapping' | 'size' | 'manual' + } + + private normalizeConfidence(value: number | null | undefined): number | undefined { + if (value === null || value === undefined) return undefined + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '五金匹配置信度必须在 0 到 1 之间') + } + return value + } + + private normalizeInstallOrder(value: number | null | undefined): number | null { + if (value === null || value === undefined) return null + if (!Number.isInteger(value) || value < 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '五金安装序号必须是非负整数') + } + return value + } + + private normalizeOptionalText(value: string | null | undefined): string | null { + const normalized = value?.trim() ?? '' + return normalized || null + } + private async getProductForPermission(productId: string): Promise { const product = await productRepository.findByIdForCollaborators(productId) if (!product || product.deletedAt) { diff --git a/furniture-3d-system/src/server/services/product-upload.service.ts b/furniture-3d-system/src/server/services/product-upload.service.ts index 17a900e4..650cfab3 100644 --- a/furniture-3d-system/src/server/services/product-upload.service.ts +++ b/furniture-3d-system/src/server/services/product-upload.service.ts @@ -14,9 +14,23 @@ import { } from '@/lib/storage' import { parseModelFile, generatePanelsFromNodes } from '@/lib/gltf-parser' import { productUploadRepository } from '../repositories/product-upload.repository' +import { releaseSkuCodeFromDeletedRecords } from './sku-soft-delete.helper' const ALLOWED_EXTENSIONS = ['.gltf', '.glb', '.dwg'] const MAX_FILE_SIZE = 100 * 1024 * 1024 +const PRODUCT_VISIBILITIES = new Set(['PRIVATE', 'TEAM', 'DEPARTMENT', 'PUBLIC']) + +function normalizeProductUploadCode(value: string | undefined): string { + return value?.trim().toUpperCase() ?? '' +} + +function normalizeProductUploadVisibility(value: string | undefined): string { + const visibility = (value || 'TEAM').trim().toUpperCase() + if (!PRODUCT_VISIBILITIES.has(visibility)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '产品可见范围无效') + } + return visibility +} export interface UploadProductResult { product: Record @@ -27,11 +41,11 @@ export interface UploadProductResult { class ProductUploadService { async uploadFromFormData(formData: FormData, user: AuthUser): Promise { - const modelNumber = (formData.get('modelNumber') as string)?.trim() + const modelNumber = normalizeProductUploadCode(formData.get('modelNumber') as string) const name = (formData.get('name') as string)?.trim() const category = (formData.get('category') as string)?.trim() const description = (formData.get('description') as string)?.trim() - const visibility = ((formData.get('visibility') as string) || 'TEAM').trim() + const visibility = normalizeProductUploadVisibility(formData.get('visibility') as string) if (!modelNumber || !name) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '产品型号和名称为必填项') @@ -106,6 +120,7 @@ class ProductUploadService { : undefined const skuCode = `${modelNumber}-01` + await releaseSkuCodeFromDeletedRecords(skuCode) const result = await productUploadRepository.createUploadedProduct({ modelNumber, name, diff --git a/furniture-3d-system/src/server/services/product-write.service.ts b/furniture-3d-system/src/server/services/product-write.service.ts index 08241d4e..8f80e0eb 100644 --- a/furniture-3d-system/src/server/services/product-write.service.ts +++ b/furniture-3d-system/src/server/services/product-write.service.ts @@ -23,13 +23,24 @@ import { validateCreateProduct, validateUpdateProduct, } from '../validators/product.validator' +import { releaseSkuCodeFromDeletedRecords } from './sku-soft-delete.helper' type GetProductByIdHandler = (id: string, ctx: ProductServiceContext) => Promise type InvalidateListCacheHandler = () => Promise -class ProductWriteService { - private static readonly SOFT_DELETE_CODE_MARKER = '__deleted__' +function normalizeProductIdentityCode(value: string): string { + return value.trim().toUpperCase() +} +function normalizeCollaboratorRole(value: string): 'editor' | 'viewer' { + const role = value.trim().toLowerCase() + if (role !== 'editor' && role !== 'viewer') { + throw new AppError(ErrorCode.VALIDATION_FAILED, '无效的协作者角色') + } + return role +} + +class ProductWriteService { constructor( private readonly repository: ProductRepositoryClass, private readonly cache: CacheService, @@ -48,13 +59,14 @@ class ProductWriteService { } const input = validation.data as CreateProductInput - const exists = await this.repository.modelNumberExists(input.modelNumber) + const modelNumber = normalizeProductIdentityCode(input.modelNumber) + const exists = await this.repository.modelNumberExists(modelNumber) if (exists) { - throw new AppError(ErrorCode.PRODUCT_DUPLICATE_MODEL, `型号 ${input.modelNumber} 已存在`) + throw new AppError(ErrorCode.PRODUCT_DUPLICATE_MODEL, `型号 ${modelNumber} 已存在`) } const product = await this.repository.create({ - modelNumber: input.modelNumber, + modelNumber, name: input.name, description: input.description, modelUrl: input.modelUrl || null, @@ -149,11 +161,7 @@ class ProductWriteService { if (!hardDelete) { const deletedAt = new Date() const skuResult = cascade - ? await this.repository.softDeleteWithCascade( - id, - deletedAt, - ProductWriteService.SOFT_DELETE_CODE_MARKER - ) + ? await this.repository.softDeleteWithCascade(id, deletedAt) : await this.repository.update(id, { deletedAt }).then(() => ({ count: 0 })) await this.cache.del(CacheKeys.product(id)) @@ -208,10 +216,7 @@ class ProductWriteService { let restoredSkuCount = 0 if (restoreSkus) { - const result = await this.repository.restoreWithSkus( - id, - ProductWriteService.SOFT_DELETE_CODE_MARKER - ) + const result = await this.repository.restoreWithSkus(id) restoredSkuCount = result.count } else { await this.repository.update(id, { deletedAt: null }) @@ -258,9 +263,11 @@ class ProductWriteService { const categoryId = input.category ? (await this.ensureCategoryIdByName(input.category)).id : null + const modelNumber = normalizeProductIdentityCode(input.modelNumber) + await releaseSkuCodeFromDeletedRecords(`${modelNumber}-CAD`) const result = await this.repository.createProductFromCAD({ - modelNumber: input.modelNumber, + modelNumber, name: input.name, description: input.description, visibility: input.visibility, @@ -286,7 +293,14 @@ class ProductWriteService { role: string, ctx: ProductServiceContext ): Promise { - const product = await this.repository.findById(productId) + const normalizedProductId = productId.trim() + const normalizedUserId = userId.trim() + const normalizedRole = normalizeCollaboratorRole(role || 'editor') + if (!normalizedProductId || !normalizedUserId) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '产品 ID 和协作者 ID 为必填项') + } + + const product = await this.repository.findById(normalizedProductId) if (!product || product.deletedAt) { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND) } @@ -299,9 +313,14 @@ class ProductWriteService { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限添加协作者') } - await this.repository.addCollaborator(productId, userId, role, ctx.user.id) - await this.cache.del(CacheKeys.product(productId)) - return this.getProductById(productId, ctx) + await this.repository.addCollaborator( + normalizedProductId, + normalizedUserId, + normalizedRole, + ctx.user.id + ) + await this.cache.del(CacheKeys.product(normalizedProductId)) + return this.getProductById(normalizedProductId, ctx) } async removeCollaborator( @@ -309,7 +328,13 @@ class ProductWriteService { userId: string, ctx: ProductServiceContext ): Promise { - const product = await this.repository.findById(productId) + const normalizedProductId = productId.trim() + const normalizedUserId = userId.trim() + if (!normalizedProductId || !normalizedUserId) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '产品 ID 和协作者 ID 为必填项') + } + + const product = await this.repository.findById(normalizedProductId) if (!product || product.deletedAt) { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND) } @@ -322,9 +347,9 @@ class ProductWriteService { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限移除协作者') } - await this.repository.removeCollaborator(productId, userId) - await this.cache.del(CacheKeys.product(productId)) - return this.getProductById(productId, ctx) + await this.repository.removeCollaborator(normalizedProductId, normalizedUserId) + await this.cache.del(CacheKeys.product(normalizedProductId)) + return this.getProductById(normalizedProductId, ctx) } private async ensureCategoryIdByName(name: string): Promise<{ id: string }> { diff --git a/furniture-3d-system/src/server/services/provider-config.service.ts b/furniture-3d-system/src/server/services/provider-config.service.ts index e67d783a..788296a6 100644 --- a/furniture-3d-system/src/server/services/provider-config.service.ts +++ b/furniture-3d-system/src/server/services/provider-config.service.ts @@ -1,5 +1,6 @@ import { prisma } from '@/lib/prisma' -import type { Prisma } from '@prisma/client' +import { Prisma } from '@prisma/client' +import { AppError, ErrorCode } from '@/server/core/errors' export interface ProviderConfigDTO { id: string @@ -32,6 +33,52 @@ export interface UpdateProviderConfigInput { extra?: Record | null } +function normalizeRequiredText(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${label}`) + } + const normalized = value.trim() + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${label}`) + } + return normalized +} + +function normalizeOptionalText(value: unknown, label: string): string | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${label}格式不正确`) + } + const normalized = value.trim() + return normalized ? normalized : null +} + +function normalizeBaseUrl(value: unknown): string { + const normalized = normalizeRequiredText(value, 'API 地址') + try { + const url = new URL(normalized) + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('unsupported protocol') + } + return normalized.replace(/\/+$/, '') + } catch { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'API 地址格式不正确') + } +} + +function normalizeJsonObject(value: unknown): Prisma.InputJsonObject | undefined { + if (value == null) return undefined + if (typeof value !== 'object' || Array.isArray(value)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '扩展配置必须是 JSON 对象') + } + try { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonObject + } catch { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '扩展配置必须是可保存的 JSON 对象') + } +} + function toDTO(record: { id: string serviceType: string @@ -62,35 +109,44 @@ function toDTO(record: { export class ProviderConfigService { async list(serviceType: string): Promise { + const normalizedServiceType = normalizeRequiredText(serviceType, '服务类型') const records = await prisma.providerConfig.findMany({ - where: { serviceType }, + where: { serviceType: normalizedServiceType }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], }) return records.map(toDTO) } async getActive(serviceType: string): Promise { + const normalizedServiceType = normalizeRequiredText(serviceType, '服务类型') const record = await prisma.providerConfig.findFirst({ - where: { serviceType, isActive: true }, + where: { serviceType: normalizedServiceType, isActive: true }, }) return record ? toDTO(record) : null } async getById(id: string): Promise { - const record = await prisma.providerConfig.findUnique({ where: { id } }) + const normalizedId = normalizeRequiredText(id, '配置 ID') + const record = await prisma.providerConfig.findUnique({ where: { id: normalizedId } }) return record ? toDTO(record) : null } async create(input: CreateProviderConfigInput): Promise { - const count = await prisma.providerConfig.count({ where: { serviceType: input.serviceType } }) + const serviceType = normalizeRequiredText(input.serviceType, '服务类型') + const name = normalizeRequiredText(input.name, '名称') + const baseUrl = normalizeBaseUrl(input.baseUrl) + const apiKey = normalizeRequiredText(input.apiKey, '密钥') + const model = normalizeOptionalText(input.model, '模型') + const extra = normalizeJsonObject(input.extra) + const count = await prisma.providerConfig.count({ where: { serviceType } }) const record = await prisma.providerConfig.create({ data: { - serviceType: input.serviceType, - name: input.name, - baseUrl: input.baseUrl, - apiKey: input.apiKey, - model: input.model ?? null, - extra: (input.extra as Prisma.InputJsonValue) ?? undefined, + serviceType, + name, + baseUrl, + apiKey, + model: model ?? null, + extra, isActive: count === 0, sortOrder: count, }, @@ -99,19 +155,21 @@ export class ProviderConfigService { } async update(id: string, input: UpdateProviderConfigInput): Promise { + const normalizedId = normalizeRequiredText(id, '配置 ID') const data: Prisma.ProviderConfigUpdateInput = {} - if (input.name !== undefined) data.name = input.name - if (input.baseUrl !== undefined) data.baseUrl = input.baseUrl - if (input.apiKey !== undefined) data.apiKey = input.apiKey - if (input.model !== undefined) data.model = input.model - if (input.extra !== undefined) data.extra = input.extra as Prisma.InputJsonValue + if (input.name !== undefined) data.name = normalizeRequiredText(input.name, '名称') + if (input.baseUrl !== undefined) data.baseUrl = normalizeBaseUrl(input.baseUrl) + if (input.apiKey !== undefined) data.apiKey = normalizeRequiredText(input.apiKey, '密钥') + if (input.model !== undefined) data.model = normalizeOptionalText(input.model, '模型') + if (input.extra !== undefined) data.extra = normalizeJsonObject(input.extra) ?? Prisma.DbNull - const record = await prisma.providerConfig.update({ where: { id }, data }) + const record = await prisma.providerConfig.update({ where: { id: normalizedId }, data }) return toDTO(record) } async activate(id: string): Promise { - const config = await prisma.providerConfig.findUnique({ where: { id } }) + const normalizedId = normalizeRequiredText(id, '配置 ID') + const config = await prisma.providerConfig.findUnique({ where: { id: normalizedId } }) if (!config) return await prisma.$transaction([ @@ -120,19 +178,20 @@ export class ProviderConfigService { data: { isActive: false }, }), prisma.providerConfig.update({ - where: { id }, + where: { id: normalizedId }, data: { isActive: true }, }), ]) } async delete(id: string): Promise { - const config = await prisma.providerConfig.findUnique({ where: { id } }) + const normalizedId = normalizeRequiredText(id, '配置 ID') + const config = await prisma.providerConfig.findUnique({ where: { id: normalizedId } }) if (!config) return if (config.isActive) { throw new Error('不能删除当前激活的配置') } - await prisma.providerConfig.delete({ where: { id } }) + await prisma.providerConfig.delete({ where: { id: normalizedId } }) } } diff --git a/furniture-3d-system/src/server/services/share.service.ts b/furniture-3d-system/src/server/services/share.service.ts index dca5e441..4e02c018 100644 --- a/furniture-3d-system/src/server/services/share.service.ts +++ b/furniture-3d-system/src/server/services/share.service.ts @@ -11,6 +11,7 @@ import { nanoid } from 'nanoid' import { canEditProduct } from '@/lib/permissions' +import { toLegacySkuDisplayAttributes } from '@/lib/sku-display-tags' import { AppError, ErrorCode } from '@/server/core/errors' import { shareRepository, @@ -21,6 +22,10 @@ import { panelRepository } from '../repositories/panel.repository' import { productRepository } from '../repositories/product.repository' import type { AuthUser } from '../core/types' import type { Prisma } from '@prisma/client' +import { + toProductSkuAssetProfileDTO, + type ProductSkuAssetProfileDTO, +} from '../dto/product.dto' // ============ 常量配置 ============ @@ -53,8 +58,10 @@ export interface PublicShareDetailDTO { skus: Array<{ id: string skuCode: string + name: string | null price: number - attributes: Record | null + assetProfile: ProductSkuAssetProfileDTO | null + legacyAttributes: Record | null }> assemblySteps: Array<{ order: number @@ -455,32 +462,23 @@ class ShareService { private toPublicSku(sku: { id: string skuCode: string + name: string | null price: unknown attributes: Prisma.JsonValue + assetProfile?: Parameters[0] }) { return { id: sku.id, skuCode: sku.skuCode, + name: sku.name, price: Number(sku.price), - attributes: this.toPublicAttributes(sku.attributes), + assetProfile: toProductSkuAssetProfileDTO(sku.assetProfile), + legacyAttributes: this.toPublicLegacyAttributes(sku.attributes), } } - private toPublicAttributes(attributes: Prisma.JsonValue): Record | null { - if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) { - return null - } - - const publicEntries = Object.entries(attributes) - .filter(([key, value]) => !key.startsWith('_') && typeof value === 'string') - .map(([key, value]) => [key, (value as string).trim()] as const) - .filter(([, value]) => value.length > 0) - - if (publicEntries.length === 0) { - return null - } - - return Object.fromEntries(publicEntries) + private toPublicLegacyAttributes(attributes: Prisma.JsonValue): Record | null { + return toLegacySkuDisplayAttributes(attributes) } private toPublicPartIds(partIds: string[]): string[] { diff --git a/furniture-3d-system/src/server/services/sku-cad-import.helper.ts b/furniture-3d-system/src/server/services/sku-cad-import.helper.ts index ea18aa66..0d2f271a 100644 --- a/furniture-3d-system/src/server/services/sku-cad-import.helper.ts +++ b/furniture-3d-system/src/server/services/sku-cad-import.helper.ts @@ -37,6 +37,7 @@ export interface ImportedCADPanel { interface CreateSkuWithImportedHolesInput { productId: string skuCode: string + name?: string attributes: Record price: number costPrice: number | null @@ -303,10 +304,12 @@ export async function createSkuWithImportedHolesTx({ ownerId, input, }: CreateSkuWithImportedHolesParams): Promise<{ id: string }> { + const skuCode = input.skuCode.trim().toUpperCase() const sku = await tx.productSKU.create({ data: { productId: input.productId, - skuCode: input.skuCode, + skuCode, + name: input.name?.trim() || skuCode, attributes: { ...input.attributes, _holesImported: input.cadHoles.length > 0 ? 'true' : 'false', diff --git a/furniture-3d-system/src/server/services/sku-command.service.ts b/furniture-3d-system/src/server/services/sku-command.service.ts index 16bae383..e76ebf01 100644 --- a/furniture-3d-system/src/server/services/sku-command.service.ts +++ b/furniture-3d-system/src/server/services/sku-command.service.ts @@ -6,6 +6,7 @@ import type { AuthUser } from '../core/types' import { formatZodErrors } from '../validators' import { validateCreateSKU, + validateCreateSKUWithImportedHoles, validateTransferSKU, validateUpdateSKU, } from '../validators/sku.validator' @@ -22,11 +23,10 @@ import { type ImportedCADPanel, } from './sku-cad-import.helper' import { - buildDeletedSkuCode, + buildRestoreSkuUpdateData, + buildSoftDeleteSkuUpdateData, executeCreateWithSkuCodeRecovery, - getRestorableSkuCode, releaseSkuCodeFromDeletedRecords, - toSkuAttributesObject, } from './sku-soft-delete.helper' import type { CreateSKUWithImportedHolesInput, @@ -35,6 +35,61 @@ import type { type GetSKUByIdHandler = (id: string, ctx: SKUServiceContext) => Promise +function readSkuDisplayName( + skuCode: string, + explicitName?: string | null +): string { + const name = explicitName?.trim() + if (name) return name + + return skuCode +} + +function normalizeSkuIdentityCode(value: string): string { + return value.trim().toUpperCase() +} + +const RESERVED_PRODUCT_SKU_ATTRIBUTE_KEYS = new Set([ + 'barcode', + 'barCode', + 'model', + 'modelNumber', + 'specCode', + 'formalSkuCode', + 'skuCode', + 'productName', + 'skuName', + 'productCategory', + 'category', + 'categoryCode', + 'seriesName', + 'seriesCode', + 'familyCode', + 'assetFamilyCode', + 'variantCode', + 'versionNo', + 'color', + 'material', + 'size', +]) + +function normalizeLegacySkuAttributes( + attributes: Record, + scope: string, +): Record { + const reservedKeys = Object.keys(attributes).filter((key) => + RESERVED_PRODUCT_SKU_ATTRIBUTE_KEYS.has(key), + ) + if (reservedKeys.length > 0) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `${scope}不能写入正式主数据字段:${reservedKeys.join(', ')}。请使用结构化 SKU 字段或资产资料副本。`, + ) + } + + return { ...attributes } +} + class SKUCommandService { constructor( private readonly repository: SKURepositoryClass, @@ -53,24 +108,27 @@ class SKUCommandService { } const input = validation.data + const skuCode = normalizeSkuIdentityCode(input.skuCode) + const legacyAttributes = normalizeLegacySkuAttributes(input.attributes, 'SKU 兼容属性') const productExists = await productRepository.existsById(input.productId) if (!productExists) { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '关联的产品不存在') } - await releaseSkuCodeFromDeletedRecords(input.skuCode) - const exists = await this.repository.skuCodeExists(input.skuCode) + await releaseSkuCodeFromDeletedRecords(skuCode) + const exists = await this.repository.skuCodeExists(skuCode) if (exists) { - throw new AppError(ErrorCode.SKU_DUPLICATE_CODE, `SKU code ${input.skuCode} already exists`) + throw new AppError(ErrorCode.SKU_DUPLICATE_CODE, `SKU code ${skuCode} already exists`) } const sku = await executeCreateWithSkuCodeRecovery({ - skuCode: input.skuCode, - duplicateMessage: `SKU code ${input.skuCode} already exists`, + skuCode, + duplicateMessage: `SKU code ${skuCode} already exists`, create: () => this.repository.create({ - skuCode: input.skuCode, - attributes: input.attributes, + skuCode, + name: readSkuDisplayName(skuCode, input.name), + attributes: legacyAttributes as Prisma.InputJsonValue, price: input.price, costPrice: input.costPrice, stock: input.stock, @@ -95,28 +153,44 @@ class SKUCommandService { input: CreateSKUWithImportedHolesInput, ctx: SKUServiceContext ): Promise<{ sku: SKUDetailDTO; holesImported: number }> { - const productExists = await productRepository.existsById(input.productId) + const validation = validateCreateSKUWithImportedHoles(input) + if (!validation.success) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + '输入验证失败', + { fields: formatZodErrors(validation.error) } + ) + } + + const validatedInput = validation.data as CreateSKUWithImportedHolesInput + const skuCode = normalizeSkuIdentityCode(validatedInput.skuCode) + const legacyAttributes = normalizeLegacySkuAttributes( + validatedInput.attributes, + 'CAD 导入 SKU 兼容属性', + ) + const productExists = await productRepository.existsById(validatedInput.productId) if (!productExists) { throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, 'Product not found') } - await releaseSkuCodeFromDeletedRecords(input.skuCode) - const codeExists = await this.repository.skuCodeExists(input.skuCode) + await releaseSkuCodeFromDeletedRecords(skuCode) + const codeExists = await this.repository.skuCodeExists(skuCode) if (codeExists) { throw new AppError(ErrorCode.SKU_DUPLICATE_CODE, 'SKU code already exists') } const created = await executeCreateWithSkuCodeRecovery({ - skuCode: input.skuCode, + skuCode, duplicateMessage: 'SKU code already exists', create: () => this.repository.withTransaction(async (tx) => createSkuWithImportedHolesTx({ tx, ownerId: ctx.user.id, - input: input as { + input: { ...validatedInput, skuCode, attributes: legacyAttributes } as { productId: string skuCode: string + name?: string attributes: Record price: number costPrice: number | null @@ -135,11 +209,11 @@ class SKUCommandService { }, }) - await this.cache.del(CacheKeys.productSkus(input.productId)) - await this.cache.del(CacheKeys.product(input.productId)) + await this.cache.del(CacheKeys.productSkus(validatedInput.productId)) + await this.cache.del(CacheKeys.product(validatedInput.productId)) await bumpProductListVersion(this.cache) const sku = await this.getSKUById(created.id, ctx) - return { sku, holesImported: input.cadHoles.length } + return { sku, holesImported: validatedInput.cadHoles.length } } async updateSKU(id: string, data: unknown, ctx: SKUServiceContext): Promise { @@ -160,7 +234,7 @@ class SKUCommandService { } const input = validation.data - const nextSkuCode = input.skuCode?.trim() + const nextSkuCode = input.skuCode === undefined ? undefined : normalizeSkuIdentityCode(input.skuCode) if (nextSkuCode && nextSkuCode !== existing.skuCode) { await releaseSkuCodeFromDeletedRecords(nextSkuCode) const codeExists = await this.repository.skuCodeExists(nextSkuCode, id) @@ -172,22 +246,16 @@ class SKUCommandService { } } - const existingAttributes = (existing.attributes || {}) as Record - const internalAttributes: Record = {} - for (const [key, value] of Object.entries(existingAttributes)) { - if (!key.startsWith('_')) continue - if (typeof value !== 'string') continue - internalAttributes[key] = value + let nextName: string | undefined + if (input.name !== undefined) { + nextName = readSkuDisplayName(nextSkuCode ?? existing.skuCode, input.name) + } else if (nextSkuCode !== undefined) { + nextName = existing.name ?? nextSkuCode } await this.repository.update(id, { ...(nextSkuCode !== undefined && { skuCode: nextSkuCode }), - ...(input.attributes !== undefined && { - attributes: { - ...input.attributes, - ...internalAttributes, - }, - }), + ...(nextName !== undefined && { name: nextName }), ...(input.price !== undefined && { price: input.price }), ...(input.costPrice !== undefined && { costPrice: input.costPrice }), ...(input.stock !== undefined && { stock: input.stock }), @@ -227,20 +295,16 @@ class SKUCommandService { await this.repository.delete(id) } else { const deletedAt = new Date() - const attributesObject = toSkuAttributesObject(existing.attributes) - const originalSkuCode = - typeof attributesObject._originalSkuCode === 'string' && attributesObject._originalSkuCode.trim() - ? attributesObject._originalSkuCode.trim() - : existing.skuCode - await this.repository.update(id, { - deletedAt, - skuCode: buildDeletedSkuCode(existing.skuCode, id, deletedAt), - attributes: { - ...attributesObject, - _originalSkuCode: originalSkuCode, - _deletedCodeAt: deletedAt.toISOString(), - } as Prisma.InputJsonValue, + await this.repository.withTransaction(async (tx) => { + await tx.productSKU.update({ + where: { id }, + data: buildSoftDeleteSkuUpdateData(existing, deletedAt), + }) + await tx.productSkuAssetProfile.updateMany({ + where: { skuId: id }, + data: { skuDeletedAt: deletedAt }, + }) }) } @@ -265,25 +329,55 @@ class SKUCommandService { throw new AppError(ErrorCode.VALIDATION_FAILED, '关联的产品已被删除,请先恢复产品') } - const attributesObject = toSkuAttributesObject(existing.attributes) - const restoredSkuCode = getRestorableSkuCode(existing.skuCode, existing.attributes) + const restoredSku = buildRestoreSkuUpdateData(existing) - const codeExists = await this.repository.skuCodeExists(restoredSkuCode, id) + const codeExists = await this.repository.skuCodeExists(restoredSku.skuCode, id) if (codeExists) { throw new AppError( ErrorCode.SKU_DUPLICATE_CODE, - `SKU code ${restoredSkuCode} is already in use and cannot be restored` + `SKU code ${restoredSku.skuCode} is already in use and cannot be restored` ) } - const restoredAttributes = { ...attributesObject } - delete restoredAttributes._originalSkuCode - delete restoredAttributes._deletedCodeAt + await this.repository.withTransaction(async (tx) => { + const assetProfile = await tx.productSkuAssetProfile.findUnique({ + where: { skuId: id }, + select: { barcode: true, sourceStatus: true }, + }) + const activeBarcode = + assetProfile?.sourceStatus === 'ACTIVE' && assetProfile.barcode?.trim() + ? assetProfile.barcode.trim().toUpperCase() + : null + if (activeBarcode) { + const barcodeConflict = await tx.productSkuAssetProfile.findFirst({ + where: { + skuId: { not: id }, + sourceStatus: 'ACTIVE', + skuDeletedAt: null, + barcode: { equals: activeBarcode, mode: 'insensitive' }, + }, + select: { skuId: true }, + }) + if (barcodeConflict) { + throw new AppError( + ErrorCode.SKU_DUPLICATE_CODE, + `SKU barcode ${activeBarcode} is already in use and cannot be restored`, + ) + } + } - await this.repository.update(id, { - deletedAt: null, - skuCode: restoredSkuCode, - attributes: restoredAttributes as Prisma.InputJsonValue, + await tx.productSKU.update({ + where: { id }, + data: { + deletedAt: null, + skuCode: restoredSku.skuCode, + attributes: restoredSku.attributes, + }, + }) + await tx.productSkuAssetProfile.updateMany({ + where: { skuId: id }, + data: { skuDeletedAt: null }, + }) }) await this.cache.del(CacheKeys.sku(id)) diff --git a/furniture-3d-system/src/server/services/sku-query.service.ts b/furniture-3d-system/src/server/services/sku-query.service.ts index 9bf69724..6bb4e3f9 100644 --- a/furniture-3d-system/src/server/services/sku-query.service.ts +++ b/furniture-3d-system/src/server/services/sku-query.service.ts @@ -8,11 +8,12 @@ import { DeletedSKUDTO, SKUDetailDTO, SKUListItemDTO, - toSKUDetailDTO, toSKUListItemDTO, + toSKUDetailDTO, } from '../dto/sku.dto' import type { SKUFilter, SKURepositoryClass } from '../repositories/sku.repository' -import { getRestorableSkuCode, toSkuAttributesObject } from './sku-soft-delete.helper' +import { toLegacySkuDisplayAttributes } from '@/lib/sku-display-tags' +import { readDeletedSkuDisplayState } from './sku-soft-delete.helper' import type { SKUListResult, SKUServiceContext } from './sku.service.types' class SKUQueryService { @@ -65,24 +66,15 @@ class SKUQueryService { _ctx: SKUServiceContext ): Promise { const skus = await this.repository.findByProductId(productId) - return skus.map((sku) => ({ - id: sku.id, - productId: sku.productId, - skuCode: sku.skuCode, - productName: '', - productModelNumber: '', - attributes: sku.attributes as Record, - price: Number(sku.price), - stock: sku.stock, - isActive: sku.isActive, - thumbnailUrl: sku.images?.[0] || null, - owner: { - id: sku.owner.id, - name: sku.owner.name, - avatar: sku.owner.avatar, - }, - createdAt: sku.createdAt.toISOString(), - })) + return skus.map((sku) => + toSKUListItemDTO({ + ...sku, + product: { + name: '', + modelNumber: '', + }, + }), + ) } async getDeletedSKUs(ctx: SKUServiceContext): Promise { @@ -108,15 +100,14 @@ class SKUQueryService { owner: { id: string; name: string; avatar: string | null } product: { id: string; name: string; modelNumber: string } }): DeletedSKUDTO { - const attributesObject = toSkuAttributesObject(sku.attributes) - const displaySkuCode = getRestorableSkuCode(sku.skuCode, sku.attributes) + const displayState = readDeletedSkuDisplayState(sku) return { id: sku.id, - skuCode: displaySkuCode, + skuCode: displayState.skuCode, deletedAt: sku.deletedAt!.toISOString(), price: Number(sku.price), - attributes: attributesObject, + legacyAttributes: toLegacySkuDisplayAttributes(displayState.attributes), owner: { id: sku.owner.id, name: sku.owner.name, diff --git a/furniture-3d-system/src/server/services/sku-soft-delete.helper.ts b/furniture-3d-system/src/server/services/sku-soft-delete.helper.ts index 5f483709..5ed491a9 100644 --- a/furniture-3d-system/src/server/services/sku-soft-delete.helper.ts +++ b/furniture-3d-system/src/server/services/sku-soft-delete.helper.ts @@ -2,6 +2,48 @@ import { prisma } from '@/lib/prisma' const SOFT_DELETE_CODE_MARKER = '__deleted__' +const RESERVED_ATTRIBUTE_KEYS = new Set([ + 'assetSkuSnapshot', + 'variantSelectionSummary', + 'bomPreview', +]) + +function normalizeSkuIdentityCode(value: string): string { + return value.trim().toUpperCase() +} + +type DeletedSkuCodeRecord = { + id: string + skuCode: string + attributes: Prisma.JsonValue | null + deletedAt: Date | null + originalSkuCode?: string | null + deletedCodeAt?: Date | null + deletedByProductId?: string | null + productDeletedAt?: Date | null +} + +type ProductSkuCodeReleaseClient = { + productSKU: { + findMany(args: { + where: Prisma.ProductSKUWhereInput + select: { + id: true + skuCode: true + attributes: true + deletedAt: true + originalSkuCode: true + deletedCodeAt: true + deletedByProductId: true + productDeletedAt: true + } + }): Promise + update(args: { + where: Prisma.ProductSKUWhereUniqueInput + data: Prisma.ProductSKUUpdateInput + }): Promise + } +} export function toSkuAttributesObject( attributes: Prisma.JsonValue | null | undefined @@ -12,66 +54,192 @@ export function toSkuAttributesObject( return {} } +function isReservedAttributeKey(key: string): boolean { + return key.startsWith('_') || RESERVED_ATTRIBUTE_KEYS.has(key) +} + +function collectPreservedAttributes(attributes: unknown): Record { + const attributesObject = toSkuAttributesObject(attributes as Prisma.JsonValue | null | undefined) + const preserved: Record = {} + + for (const [key, value] of Object.entries(attributesObject)) { + if (isReservedAttributeKey(key)) { + preserved[key] = value + } + } + + return preserved +} + +function removeReservedAttributeInput(attributes: unknown): Record { + const attributesObject = toSkuAttributesObject(attributes as Prisma.JsonValue | null | undefined) + return Object.fromEntries( + Object.entries(attributesObject).filter(([key]) => !isReservedAttributeKey(key)), + ) +} + +export function removeSoftDeleteLifecycleAttributes( + attributes: Record, +): Record { + const nextAttributes = { ...attributes } + delete nextAttributes._originalSkuCode + delete nextAttributes._deletedCodeAt + delete nextAttributes._deletedByProductId + delete nextAttributes._productDeletedAt + return nextAttributes +} + +export function mergeSkuAttributesForUpdate( + existingAttributes: Prisma.JsonValue | null | undefined, + inputAttributes: unknown, +): Prisma.InputJsonValue { + return { + ...removeReservedAttributeInput(inputAttributes), + ...collectPreservedAttributes(existingAttributes), + } as Prisma.InputJsonValue +} + +export function mergeSkuRecordAttributesForUpdate( + record: Pick, + inputAttributes: unknown, +): Prisma.InputJsonValue { + return mergeSkuAttributesForUpdate(record.attributes, inputAttributes) +} + export function buildDeletedSkuCode(skuCode: string, id: string, deletedAt: Date): string { const timestamp = deletedAt.getTime() - return `${skuCode}${SOFT_DELETE_CODE_MARKER}${timestamp}_${id.slice(0, 8)}` + return `${normalizeSkuIdentityCode(skuCode)}${SOFT_DELETE_CODE_MARKER}${timestamp}_${id.slice(0, 8)}` } export function getRestorableSkuCode( skuCode: string, - attributes: Prisma.JsonValue | null | undefined + originalSkuCode?: string | null, ): string { - const attributesObject = toSkuAttributesObject(attributes) - const metadataOriginal = attributesObject._originalSkuCode - - if (typeof metadataOriginal === 'string' && metadataOriginal.length > 0) { - return metadataOriginal + if (typeof originalSkuCode === 'string' && originalSkuCode.trim().length > 0) { + return normalizeSkuIdentityCode(originalSkuCode) } const markerIndex = skuCode.indexOf(SOFT_DELETE_CODE_MARKER) if (markerIndex > 0) { - return skuCode.slice(0, markerIndex) + return normalizeSkuIdentityCode(skuCode.slice(0, markerIndex)) } - return skuCode + return normalizeSkuIdentityCode(skuCode) } -export async function releaseSkuCodeFromDeletedRecords(skuCode: string): Promise { - const deletedRecords = await prisma.productSKU.findMany({ +export function buildSoftDeleteSkuUpdateData( + record: DeletedSkuCodeRecord, + deletedAt: Date, +): Prisma.ProductSKUUpdateInput { + const attributesObject = removeSoftDeleteLifecycleAttributes( + toSkuAttributesObject(record.attributes), + ) + const originalSkuCode = + getRestorableSkuCode(record.skuCode, record.originalSkuCode) + + return { + deletedAt, + skuCode: buildDeletedSkuCode(originalSkuCode, record.id, deletedAt), + originalSkuCode, + deletedCodeAt: deletedAt, + attributes: { + ...attributesObject, + } as Prisma.InputJsonValue, + } +} + +export function buildRestoreSkuUpdateData(record: Pick): { + skuCode: string + attributes: Prisma.InputJsonValue + originalSkuCode: null + deletedCodeAt: null + deletedByProductId: null + productDeletedAt: null +} { + const restoredSkuCode = getRestorableSkuCode(record.skuCode, record.originalSkuCode) + const restoredAttributes = removeSoftDeleteLifecycleAttributes( + toSkuAttributesObject(record.attributes), + ) + + return { + skuCode: restoredSkuCode, + attributes: restoredAttributes as Prisma.InputJsonValue, + originalSkuCode: null, + deletedCodeAt: null, + deletedByProductId: null, + productDeletedAt: null, + } +} + +export function readDeletedSkuDisplayState(record: Pick): { + skuCode: string + attributes: Record +} { + return { + skuCode: getRestorableSkuCode(record.skuCode, record.originalSkuCode), + attributes: removeSoftDeleteLifecycleAttributes(toSkuAttributesObject(record.attributes)), + } +} + +export async function releaseSkuCodeFromDeletedRecordsTx( + client: ProductSkuCodeReleaseClient, + skuCode: string, +): Promise { + const normalizedSkuCode = normalizeSkuIdentityCode(skuCode) + const deletedRecords = await client.productSKU.findMany({ where: { deletedAt: { not: null }, - OR: [{ skuCode }, { skuCode: { startsWith: `${skuCode}${SOFT_DELETE_CODE_MARKER}` } }], + OR: [ + { skuCode: { equals: normalizedSkuCode, mode: 'insensitive' } }, + { + skuCode: { + startsWith: `${normalizedSkuCode}${SOFT_DELETE_CODE_MARKER}`, + mode: 'insensitive', + }, + }, + ], }, select: { id: true, skuCode: true, attributes: true, deletedAt: true, + originalSkuCode: true, + deletedCodeAt: true, + deletedByProductId: true, + productDeletedAt: true, }, }) for (const record of deletedRecords) { - const attributesObject = toSkuAttributesObject(record.attributes) - const originalSkuCode = getRestorableSkuCode(record.skuCode, record.attributes) + const attributesObject = removeSoftDeleteLifecycleAttributes( + toSkuAttributesObject(record.attributes), + ) + const originalSkuCode = getRestorableSkuCode(record.skuCode, record.originalSkuCode) - if (originalSkuCode !== skuCode) { + if (originalSkuCode !== normalizedSkuCode) { continue } const deletedAt = record.deletedAt ?? new Date() - await prisma.productSKU.update({ + await client.productSKU.update({ where: { id: record.id }, data: { - skuCode: buildDeletedSkuCode(record.skuCode, record.id, deletedAt), + skuCode: buildDeletedSkuCode(originalSkuCode, record.id, deletedAt), + originalSkuCode, + deletedCodeAt: record.deletedCodeAt ?? deletedAt, attributes: { ...attributesObject, - _originalSkuCode: originalSkuCode, } as Prisma.InputJsonValue, }, }) } } +export async function releaseSkuCodeFromDeletedRecords(skuCode: string): Promise { + await releaseSkuCodeFromDeletedRecordsTx(prisma, skuCode) +} + export function isSkuCodeUniqueConflict(error: unknown): boolean { if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { return false diff --git a/furniture-3d-system/src/server/services/sku-upload.service.ts b/furniture-3d-system/src/server/services/sku-upload.service.ts index 726d9e41..a4c65eda 100644 --- a/furniture-3d-system/src/server/services/sku-upload.service.ts +++ b/furniture-3d-system/src/server/services/sku-upload.service.ts @@ -19,6 +19,7 @@ import { sanitizePanelSize, } from '@/lib/cad-panel-linking' import { normalizeObjPanelNodeNames } from '@/lib/obj-panel-node-normalizer' +import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' import { AppError, ErrorCode } from '@/server/core/errors' import type { ImportedCADPanel } from './sku-cad-import.helper' @@ -235,7 +236,7 @@ class SKUUploadService { return { productId, - skuCode: skuCode.trim(), + skuCode: normalizeCoreIdentity(skuCode), attributes: extendedAttributes, price: this.parseNonNegativeNumber(price, '销售价格', 0), costPrice: costPrice.trim() ? this.parseNonNegativeNumber(costPrice, '成本价格', 0) : null, diff --git a/furniture-3d-system/src/server/services/sku.service.types.ts b/furniture-3d-system/src/server/services/sku.service.types.ts index 380b2220..d6e5ea7d 100644 --- a/furniture-3d-system/src/server/services/sku.service.types.ts +++ b/furniture-3d-system/src/server/services/sku.service.types.ts @@ -15,6 +15,7 @@ export interface SKUListResult { export interface CreateSKUWithImportedHolesInput { productId: string skuCode: string + name?: string attributes: Record price: number costPrice: number | null diff --git a/furniture-3d-system/src/server/services/system-config.service.ts b/furniture-3d-system/src/server/services/system-config.service.ts index e689d3f3..e829c550 100644 --- a/furniture-3d-system/src/server/services/system-config.service.ts +++ b/furniture-3d-system/src/server/services/system-config.service.ts @@ -1,5 +1,6 @@ import { prisma } from '@/lib/prisma' import type { Prisma } from '@prisma/client' +import { AppError, ErrorCode } from '@/server/core/errors' const CACHE_TTL_MS = 60_000 @@ -8,34 +9,73 @@ interface CacheEntry { expiresAt: number } +function normalizeConfigKey(value: unknown): string { + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少配置 key') + } + const normalized = value.trim() + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少配置 key') + } + return normalized +} + +function normalizeOptionalText(value: unknown): string | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value !== 'string') { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '配置说明格式不正确') + } + const normalized = value.trim() + return normalized ? normalized : null +} + +function ensureJsonCompatible(value: unknown): Prisma.InputJsonValue { + if (value === undefined) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少配置 value') + } + try { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue + } catch { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '配置 value 必须是可保存的 JSON') + } +} + export class SystemConfigService { private cache = new Map() async get(key: string): Promise { - const cached = this.cache.get(key) + const normalizedKey = normalizeConfigKey(key) + const cached = this.cache.get(normalizedKey) if (cached && cached.expiresAt > Date.now()) { return cached.value as T } - const record = await prisma.systemConfig.findUnique({ where: { key } }) + const record = await prisma.systemConfig.findUnique({ where: { key: normalizedKey } }) if (!record) return null - this.cache.set(key, { value: record.value, expiresAt: Date.now() + CACHE_TTL_MS }) + this.cache.set(normalizedKey, { value: record.value, expiresAt: Date.now() + CACHE_TTL_MS }) return record.value as T } async set(key: string, value: unknown, description?: string): Promise { + const normalizedKey = normalizeConfigKey(key) + const normalizedValue = ensureJsonCompatible(value) + const normalizedDescription = normalizeOptionalText(description) const data: Prisma.SystemConfigUpsertArgs = { - where: { key }, - create: { key, value: value as Prisma.InputJsonValue, description: description ?? null }, - update: { value: value as Prisma.InputJsonValue, ...(description !== undefined ? { description } : {}) }, + where: { key: normalizedKey }, + create: { key: normalizedKey, value: normalizedValue, description: normalizedDescription ?? null }, + update: { + value: normalizedValue, + ...(normalizedDescription !== undefined ? { description: normalizedDescription } : {}), + }, } await prisma.systemConfig.upsert(data) - this.cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }) + this.cache.set(normalizedKey, { value: normalizedValue, expiresAt: Date.now() + CACHE_TTL_MS }) } invalidate(key: string): void { - this.cache.delete(key) + this.cache.delete(key.trim()) } } diff --git a/furniture-3d-system/src/server/services/user.service.ts b/furniture-3d-system/src/server/services/user.service.ts index cc367778..b0e839d9 100644 --- a/furniture-3d-system/src/server/services/user.service.ts +++ b/furniture-3d-system/src/server/services/user.service.ts @@ -89,6 +89,44 @@ export interface UserDTO { const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const NAME_REGEX = /^[\u4e00-\u9fa5a-zA-Z0-9_]{2,20}$/ const GENERIC_LOGIN_ERROR_MESSAGE = '账号或密码错误' +const BUSINESS_ROLE_SET = new Set([ + 'DESIGN', + 'PLANNING', + 'PURCHASING', + 'WAREHOUSE', + 'SUPPLIER', +]) + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +function normalizeName(name: string): string { + return name.trim() +} + +function normalizeOptionalText(value: string | null | undefined): string | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + const normalized = value.trim() + return normalized || null +} + +function normalizeOptionalIdentity(value: string | null | undefined): string | null | undefined { + return normalizeOptionalText(value) +} + +function normalizeBusinessRoles(value: BusinessAccountType[] | undefined): BusinessAccountType[] | undefined { + if (value === undefined) return undefined + const roles: BusinessAccountType[] = [] + for (const role of value) { + if (!BUSINESS_ROLE_SET.has(role)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '业务类型不合法') + } + roles.push(role) + } + return [...new Set(roles)] +} function validateEmail(email: string): void { if (!email || !EMAIL_REGEX.test(email)) { @@ -230,12 +268,12 @@ class UserService { } async getByEmail(email: string): Promise { - const user = await userRepository.findByEmailWithRelations(email) + const user = await userRepository.findByEmailWithRelations(normalizeEmail(email)) return user ? this.toDTO(user) : null } async getByName(name: string): Promise { - const user = await userRepository.findByNameWithRelations(name) + const user = await userRepository.findByNameWithRelations(normalizeName(name)) return user ? this.toDTO(user) : null } @@ -249,16 +287,19 @@ class UserService { } async create(input: CreateUserInput): Promise { - validateEmail(input.email) - validateName(input.name) + const email = normalizeEmail(input.email) + const name = normalizeName(input.name) + const businessRoles = normalizeBusinessRoles(input.businessRoles) ?? [] + validateEmail(email) + validateName(name) validatePassword(input.password) - const emailExists = await userRepository.emailExists(input.email) + const emailExists = await userRepository.emailExists(email) if (emailExists) { throw new AppError(ErrorCode.USER_EMAIL_EXISTS, '该邮箱已被注册') } - const nameExists = await userRepository.nameExists(input.name) + const nameExists = await userRepository.nameExists(name) if (nameExists) { throw new AppError(ErrorCode.USER_NAME_EXISTS, '该姓名已被使用') } @@ -272,22 +313,22 @@ class UserService { const hashedPassword = await hashPassword(input.password) const created = await userRepository.createWithScalars({ - email: input.email, - name: input.name, + email, + name, password: hashedPassword, role: input.role ?? 'USER', - businessRoles: input.businessRoles ?? [], + businessRoles, departmentId: input.departmentId, teamId: null, - phone: input.phone, + phone: normalizeOptionalText(input.phone), isFirstLogin: input.isFirstLogin ?? false, status: input.status ?? 'PENDING', - loginSource: input.loginSource ?? 'LOCAL', - employeeDirectoryId: input.employeeDirectoryId, - dingtalkUserId: input.dingtalkUserId, - dingtalkUnionId: input.dingtalkUnionId, + loginSource: normalizeOptionalText(input.loginSource) ?? 'LOCAL', + employeeDirectoryId: normalizeOptionalIdentity(input.employeeDirectoryId), + dingtalkUserId: normalizeOptionalIdentity(input.dingtalkUserId), + dingtalkUnionId: normalizeOptionalIdentity(input.dingtalkUnionId), dingtalkBoundAt: input.dingtalkBoundAt, - avatar: input.avatar, + avatar: normalizeOptionalText(input.avatar), }) const user = await userRepository.findByIdWithRelations(created.id) @@ -305,9 +346,10 @@ class UserService { } if (input.email !== undefined) { - validateEmail(input.email) - if (input.email !== existing.email) { - const emailExists = await userRepository.emailExists(input.email, id) + const email = normalizeEmail(input.email) + validateEmail(email) + if (email !== existing.email) { + const emailExists = await userRepository.emailExists(email, id) if (emailExists) { throw new AppError(ErrorCode.USER_EMAIL_EXISTS, '该邮箱已被其他用户使用') } @@ -315,9 +357,10 @@ class UserService { } if (input.name !== undefined) { - validateName(input.name) - if (input.name !== existing.name) { - const nameExists = await userRepository.nameExists(input.name, id) + const name = normalizeName(input.name) + validateName(name) + if (name !== existing.name) { + const nameExists = await userRepository.nameExists(name, id) if (nameExists) { throw new AppError(ErrorCode.USER_NAME_EXISTS, '该姓名已被其他用户使用') } @@ -331,16 +374,16 @@ class UserService { } await userRepository.updateWithScalars(id, { - email: input.email, - name: input.name, + email: input.email === undefined ? undefined : normalizeEmail(input.email), + name: input.name === undefined ? undefined : normalizeName(input.name), password: hashedPassword, role: input.role, - businessRoles: input.businessRoles, + businessRoles: normalizeBusinessRoles(input.businessRoles), departmentId: input.departmentId, teamId: input.teamId === undefined ? undefined : null, - phone: input.phone, + phone: normalizeOptionalText(input.phone), status: input.status, - avatar: input.avatar, + avatar: normalizeOptionalText(input.avatar), }) const updatedUser = await userRepository.findByIdWithRelations(id) @@ -364,8 +407,9 @@ class UserService { identifier: string, password: string ): Promise<{ user: UserDTO; isEmail: boolean }> { - const isEmail = EMAIL_REGEX.test(identifier) - const user = await userRepository.findByIdentifier(identifier) + const normalizedIdentifier = identifier.trim() + const isEmail = EMAIL_REGEX.test(normalizedIdentifier) + const user = await userRepository.findByIdentifier(isEmail ? normalizeEmail(normalizedIdentifier) : normalizedIdentifier) if (!user) { throw new AppError(ErrorCode.AUTH_INVALID_CREDENTIALS, GENERIC_LOGIN_ERROR_MESSAGE) diff --git a/furniture-3d-system/src/server/services/warehouse.service.ts b/furniture-3d-system/src/server/services/warehouse.service.ts index d5d329f7..c129b226 100644 --- a/furniture-3d-system/src/server/services/warehouse.service.ts +++ b/furniture-3d-system/src/server/services/warehouse.service.ts @@ -37,19 +37,55 @@ function toLocationDTO(record: WarehouseLocation): WarehouseLocationDTO { } } -function readSkuName(attributes: unknown, fallbackSkuCode: string) { - if ( - typeof attributes === 'object' && - attributes && - !Array.isArray(attributes) && - typeof (attributes as Record).skuName === 'string' - ) { - return (attributes as Record).skuName as string - } +function readSkuName( + name: string | null | undefined, + assetProfile: { specName?: string | null } | null | undefined, + fallbackSkuCode: string, +) { + const normalizedName = name?.trim() + if (normalizedName) return normalizedName + + const specName = assetProfile?.specName?.trim() + if (specName) return specName return fallbackSkuCode } +function normalizeWarehouseCode(value: string): string { + return value.trim().toUpperCase() +} + +const BRAND_MATERIAL_TYPES = new Set(['MANUAL', 'NAMEPLATE', 'CERTIFICATE', 'CARTON_PRINT']) + +function normalizeOptionalText(value: string | null | undefined): string | null { + return value?.trim() || null +} + +function normalizeRequiredId(value: string | null | undefined, fieldName: string): string { + const next = value?.trim() ?? '' + if (!next) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${fieldName}不能为空`) + } + return next +} + +function normalizeBrandMaterialInput(brand: string, materialType: string) { + const normalizedBrand = brand.trim() + const normalizedMaterialType = materialType.trim().toUpperCase() + + if (!normalizedBrand || !normalizedMaterialType) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '品牌和物料类型不能为空') + } + if (!BRAND_MATERIAL_TYPES.has(normalizedMaterialType)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '品牌物料类型不在允许范围内') + } + + return { + brand: normalizedBrand, + materialType: normalizedMaterialType, + } +} + class WarehouseService { async listLocations(): Promise { const locations = await warehouseLocationRepository.findAllActive() @@ -57,7 +93,7 @@ class WarehouseService { } async createLocation(input: CreateWarehouseLocationInput): Promise { - const code = input.code.trim() + const code = normalizeWarehouseCode(input.code) const name = input.name.trim() if (!code || !name) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '库位编码和名称不能为空') @@ -67,10 +103,10 @@ class WarehouseService { throw new AppError(ErrorCode.VALIDATION_FAILED, '库位编码已存在') } - const zone = input.zone?.trim() || null - const aisle = input.aisle?.trim() || null - const shelf = input.shelf?.trim() || null - const bin = input.bin?.trim() || null + const zone = normalizeOptionalText(input.zone) + const aisle = normalizeOptionalText(input.aisle) + const shelf = normalizeOptionalText(input.shelf) + const bin = normalizeOptionalText(input.bin) if (await warehouseLocationRepository.findByPosition(zone, aisle, shelf, bin)) { const parts = [zone || '未分区', aisle || '未设巷道', shelf || '未设货架', bin || '未设箱位'] @@ -84,7 +120,7 @@ class WarehouseService { aisle, shelf, bin, - notes: input.notes?.trim() || null, + notes: normalizeOptionalText(input.notes), isActive: true, }) @@ -92,16 +128,54 @@ class WarehouseService { } async updateLocation(id: string, input: UpdateWarehouseLocationInput): Promise { + const existing = await warehouseLocationRepository.findById(id) + if (!existing) { + throw new AppError(ErrorCode.WAREHOUSE_LOCATION_NOT_FOUND, '库位不存在') + } + const data: Record = {} - if (input.code !== undefined) data.code = input.code.trim() - if (input.name !== undefined) data.name = input.name.trim() - if (input.zone !== undefined) data.zone = input.zone?.trim() || null - if (input.aisle !== undefined) data.aisle = input.aisle?.trim() || null - if (input.shelf !== undefined) data.shelf = input.shelf?.trim() || null - if (input.bin !== undefined) data.bin = input.bin?.trim() || null - if (input.notes !== undefined) data.notes = input.notes?.trim() || null + if (input.code !== undefined) { + const code = normalizeWarehouseCode(input.code) + if (!code) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '库位编码不能为空') + } + const duplicate = code === existing.code ? null : await warehouseLocationRepository.findByCode(code) + if (duplicate && duplicate.id !== id) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '库位编码已存在') + } + data.code = code + } + if (input.name !== undefined) { + const name = input.name.trim() + if (!name) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '库位名称不能为空') + } + data.name = name + } + const nextZone = input.zone !== undefined ? normalizeOptionalText(input.zone) : existing.zone + const nextAisle = input.aisle !== undefined ? normalizeOptionalText(input.aisle) : existing.aisle + const nextShelf = input.shelf !== undefined ? normalizeOptionalText(input.shelf) : existing.shelf + const nextBin = input.bin !== undefined ? normalizeOptionalText(input.bin) : existing.bin + + if (input.zone !== undefined) data.zone = nextZone + if (input.aisle !== undefined) data.aisle = nextAisle + if (input.shelf !== undefined) data.shelf = nextShelf + if (input.bin !== undefined) data.bin = nextBin + if (input.notes !== undefined) data.notes = normalizeOptionalText(input.notes) if (input.isActive !== undefined) data.isActive = input.isActive + const activatesLocation = input.isActive === true && !existing.isActive + const changesPosition = + nextZone !== existing.zone || + nextAisle !== existing.aisle || + nextShelf !== existing.shelf || + nextBin !== existing.bin + + if ((activatesLocation || (existing.isActive && changesPosition)) && await warehouseLocationRepository.findByPosition(nextZone, nextAisle, nextShelf, nextBin, id)) { + const parts = [nextZone || '未分区', nextAisle || '未设巷道', nextShelf || '未设货架', nextBin || '未设箱位'] + throw new AppError(ErrorCode.VALIDATION_FAILED, `库位 ${parts.join(' / ')} 已存在,不可重复创建`) + } + const updated = await warehouseLocationRepository.update(id, data) return toLocationDTO(updated) } @@ -124,7 +198,7 @@ class WarehouseService { skuStock: skuStock.map((sku) => ({ skuId: sku.id, skuCode: sku.skuCode, - skuName: readSkuName(sku.attributes, sku.skuCode), + skuName: readSkuName(sku.name, sku.assetProfile, sku.skuCode), productId: sku.product.id, productName: sku.product.name, productModelNumber: sku.product.modelNumber, @@ -168,11 +242,8 @@ class WarehouseService { } async bindVariantToLocations(input: BindVariantToLocationsInput): Promise { - const { variantId, locationIds } = input - - if (!variantId) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '变体 ID 不能为空') - } + const variantId = normalizeRequiredId(input.variantId, '变体 ID') + const locationIds = [...new Set((input.locationIds ?? []).map((id) => normalizeRequiredId(id, '库位 ID')))] if (!locationIds || locationIds.length === 0) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少选择一个库位') } @@ -200,11 +271,8 @@ class WarehouseService { } async unbindVariantFromLocation(input: UnbindVariantFromLocationInput): Promise { - const { variantId, locationId } = input - - if (!variantId || !locationId) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '变体 ID 和库位 ID 不能为空') - } + const variantId = normalizeRequiredId(input.variantId, '变体 ID') + const locationId = normalizeRequiredId(input.locationId, '库位 ID') try { await warehouseLocationRepository.deleteBindingByPair(variantId, locationId) @@ -214,10 +282,11 @@ class WarehouseService { } async getBrandMaterials(brand: string): Promise { - if (!brand) { + const normalizedBrand = brand.trim() + if (!normalizedBrand) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '品牌不能为空') } - const bindings = await warehouseLocationRepository.findBrandMaterialBindingsByBrand(brand) + const bindings = await warehouseLocationRepository.findBrandMaterialBindingsByBrand(normalizedBrand) return bindings.map((b) => ({ id: b.id, brand: b.brand, @@ -232,11 +301,8 @@ class WarehouseService { } async bindBrandMaterialLocation(input: BindBrandMaterialLocationInput): Promise { - const { brand, materialType, warehouseLocationId } = input - - if (!brand || !materialType) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '品牌和物料类型不能为空') - } + const { brand, materialType } = normalizeBrandMaterialInput(input.brand, input.materialType) + const warehouseLocationId = input.warehouseLocationId?.trim() || null if (warehouseLocationId) { const location = await warehouseLocationRepository.findById(warehouseLocationId) @@ -265,11 +331,9 @@ class WarehouseService { } async uploadBrandMaterialFile(brand: string, materialType: string, file: File): Promise { - if (!brand || !materialType) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '品牌和物料类型不能为空') - } + const normalized = normalizeBrandMaterialInput(brand, materialType) - if (materialType !== 'CARTON_PRINT') { + if (normalized.materialType !== 'CARTON_PRINT') { throw new AppError(ErrorCode.VALIDATION_FAILED, '仅外箱印刷支持文件上传') } @@ -282,7 +346,7 @@ class WarehouseService { throw new AppError(ErrorCode.FILE_TOO_LARGE, '文件大小不能超过 100MB') } - const key = `brand-materials/${brand}/${materialType}/${randomUUID()}.zip` + const key = `brand-materials/${normalized.brand}/${normalized.materialType}/${randomUUID()}.zip` const fileUrl = await uploadBufferToS3( Buffer.from(await file.arrayBuffer()), key, @@ -290,10 +354,10 @@ class WarehouseService { ) const result = await warehouseLocationRepository.upsertBrandMaterialBinding({ - brand, - materialType, + brand: normalized.brand, + materialType: normalized.materialType, fileUrl, - fileName: file.name, + fileName: file.name.trim(), fileSize: file.size, }) @@ -325,11 +389,8 @@ class WarehouseService { } async bindPreSkuToLocations(input: BindPreSkuToLocationsInput): Promise { - const { preSkuId, locationIds } = input - - if (!preSkuId) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'PreSku ID 不能为空') - } + const preSkuId = normalizeRequiredId(input.preSkuId, 'PreSku ID') + const locationIds = [...new Set((input.locationIds ?? []).map((id) => normalizeRequiredId(id, '库位 ID')))] if (!locationIds || locationIds.length === 0) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少选择一个库位') } @@ -352,11 +413,8 @@ class WarehouseService { } async unbindPreSkuFromLocation(input: UnbindPreSkuFromLocationInput): Promise { - const { preSkuId, locationId } = input - - if (!preSkuId || !locationId) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'PreSku ID 和库位 ID 不能为空') - } + const preSkuId = normalizeRequiredId(input.preSkuId, 'PreSku ID') + const locationId = normalizeRequiredId(input.locationId, '库位 ID') try { await warehouseLocationRepository.deletePreSkuBindingByPair(preSkuId, locationId) diff --git a/furniture-3d-system/src/server/validators/sku.validator.test.ts b/furniture-3d-system/src/server/validators/sku.validator.test.ts index 38aa8332..35e2c68a 100644 --- a/furniture-3d-system/src/server/validators/sku.validator.test.ts +++ b/furniture-3d-system/src/server/validators/sku.validator.test.ts @@ -16,4 +16,18 @@ describe('updateSKUSchema', () => { expect(result.success).toBe(false) }) + + it('普通更新入口不再接收 attributes 旧属性袋', () => { + const result = validateUpdateSKU({ + price: 99, + attributes: { + color: 'black', + }, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual({ price: 99 }) + } + }) }) diff --git a/furniture-3d-system/src/server/validators/sku.validator.ts b/furniture-3d-system/src/server/validators/sku.validator.ts index ea7e5ba4..a716dcf1 100644 --- a/furniture-3d-system/src/server/validators/sku.validator.ts +++ b/furniture-3d-system/src/server/validators/sku.validator.ts @@ -12,6 +12,25 @@ const skuCodeSchema = z .max(50, 'SKU编码最长50个字符') .regex(/^[A-Za-z0-9-_]+$/, 'SKU编码只能包含字母、数字、横线和下划线') +const resourceUrlSchema = z + .string() + .trim() + .refine((value) => { + if (value.startsWith('/')) return true + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } + }, '资源地址必须是站内路径或 http(s) URL') + +const vector3Schema = z.tuple([ + z.coerce.number().finite(), + z.coerce.number().finite(), + z.coerce.number().finite(), +]) + // ============ 创建 SKU 验证 ============ export const createSKUSchema = z.object({ @@ -20,6 +39,13 @@ export const createSKUSchema = z.object({ .cuid('产品ID格式不正确'), skuCode: skuCodeSchema, + + name: z + .string() + .trim() + .min(1, 'SKU名称不能为空') + .max(100, 'SKU名称最长100个字符') + .optional(), attributes: z .record(z.string(), z.string()) @@ -62,15 +88,95 @@ export const createSKUSchema = z.object({ export type CreateSKUInput = z.infer +// ============ CAD 导入创建 SKU 验证 ============ + +export const createSKUWithImportedHolesSchema = z.object({ + productId: z + .string() + .trim() + .min(1, '产品ID不能为空'), + + skuCode: skuCodeSchema, + + name: z + .string() + .trim() + .min(1, 'SKU名称不能为空') + .max(100, 'SKU名称最长100个字符') + .optional(), + + attributes: z + .record(z.string(), z.unknown()) + .default({}), + + price: z + .number() + .min(0, '价格不能为负数') + .max(9999999.99, '价格超出范围'), + + costPrice: z + .number() + .min(0, '成本价不能为负数') + .max(9999999.99, '成本价超出范围') + .nullable(), + + stock: z + .number() + .int('库存必须是整数') + .min(0, '库存不能为负数'), + + modelUrl: resourceUrlSchema + .nullable(), + + images: z + .array(resourceUrlSchema) + .default([]), + + cadVersion: z + .string() + .trim() + .min(1, 'CAD版本不能为空'), + + cadPanels: z.array(z.object({ + id: z.string().trim().min(1, '板件ID不能为空'), + panelType: z.string().optional(), + size: vector3Schema, + position: vector3Schema.optional(), + normal: vector3Schema.optional(), + boundingBox: z.object({ + min: vector3Schema, + max: vector3Schema, + }).optional(), + sourceLayer: z.string().optional(), + }).passthrough()).default([]), + + cadHoles: z.array(z.object({ + panelId: z.string().trim().min(1, '孔位 panelId 不能为空'), + position: vector3Schema, + normal: vector3Schema, + diameter: z.coerce.number().positive('孔径必须大于0'), + depth: z.coerce.number().nonnegative('孔深不能为负数'), + inferredUsage: z.string().optional(), + confidence: z.coerce.number().min(0).max(1).optional(), + }).passthrough()).default([]), + + cadSourceData: z.unknown().nullable(), +}) + +export type CreateSKUWithImportedHolesValidatedInput = z.infer + // ============ 更新 SKU 验证 ============ export const updateSKUSchema = z.object({ skuCode: skuCodeSchema.optional(), - attributes: z - .record(z.string(), z.string()) + name: z + .string() + .trim() + .min(1, 'SKU名称不能为空') + .max(100, 'SKU名称最长100个字符') .optional(), - + price: z .number() .min(0, '价格不能为负数') @@ -129,6 +235,10 @@ export function validateCreateSKU(data: unknown) { return createSKUSchema.safeParse(data) } +export function validateCreateSKUWithImportedHoles(data: unknown) { + return createSKUWithImportedHolesSchema.safeParse(data) +} + export function validateUpdateSKU(data: unknown) { return updateSKUSchema.safeParse(data) } diff --git a/scripts/deploy-feiniu-manual.md b/scripts/deploy-feiniu-manual.md index 0fd292c3..af52e5c9 100644 --- a/scripts/deploy-feiniu-manual.md +++ b/scripts/deploy-feiniu-manual.md @@ -73,7 +73,8 @@ docker-compose -f docker-compose.prod.yml logs -f ```bash # 等待服务启动完成后,执行数据库初始化 -docker-compose -f docker-compose.prod.yml exec app npx prisma db push +docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy +docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict docker-compose -f docker-compose.prod.yml exec app npx prisma db seed ``` diff --git a/scripts/prepare-deploy-package.ps1 b/scripts/prepare-deploy-package.ps1 index 7fe0946e..819fcaf5 100644 --- a/scripts/prepare-deploy-package.ps1 +++ b/scripts/prepare-deploy-package.ps1 @@ -118,7 +118,8 @@ echo "" # 初始化数据库(如果需要) echo "初始化数据库..." -docker-compose -f docker-compose.prod.yml exec -T app npx prisma db push || echo "⚠️ 数据库初始化失败,请检查日志" +docker-compose -f docker-compose.prod.yml exec -T app npx prisma migrate deploy || echo "⚠️ 数据库迁移失败,请检查日志" +docker-compose -f docker-compose.prod.yml exec -T app npm run db:verify-master-data:all:strict || echo "⚠️ 主数据地基巡检失败,请检查迁移和数据库护栏" echo "" # 检查健康状态 @@ -175,7 +176,8 @@ docker-compose -f docker-compose.prod.yml up -d docker-compose -f docker-compose.prod.yml logs -f # 初始化数据库 -docker-compose -f docker-compose.prod.yml exec app npx prisma db push +docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy +docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict docker-compose -f docker-compose.prod.yml exec app npx prisma db seed ``` diff --git a/scripts/部署到飞牛OS-快速指南.md b/scripts/部署到飞牛OS-快速指南.md index 37d35472..ea40fb55 100644 --- a/scripts/部署到飞牛OS-快速指南.md +++ b/scripts/部署到飞牛OS-快速指南.md @@ -62,7 +62,8 @@ docker-compose -f docker-compose.prod.yml logs -f 等待服务启动完成后(约5-10分钟),执行: ```bash -docker-compose -f docker-compose.prod.yml exec app npx prisma db push +docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy +docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict docker-compose -f docker-compose.prod.yml exec app npx prisma db seed ```