chore(data-foundation): sync xuxiake mainline and stabilize master data
This commit is contained in:
parent
35fed448cf
commit
f0d3f5daae
4
furniture-3d-system/.gitignore
vendored
4
furniture-3d-system/.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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` 和疑似生产库名。
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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:页面没变化
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 $$;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
});
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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<T>(
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
@ -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<string, unknown> | null
|
||||
)
|
||||
try {
|
||||
for (const sku of skus) {
|
||||
scanned += 1
|
||||
const cadData = readSkuCadDataFromAttributes(
|
||||
(sku.attributes ?? null) as Record<string, unknown> | 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()
|
||||
})
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<unknown>): 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();
|
||||
});
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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<string, number> = {
|
||||
密度板: 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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, { model: keyof typeof THREE_JS_MODELS; thumbnail?: keyof typeof THUMBNAILS }> = {
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 } }>
|
||||
}) => (
|
||||
<div>
|
||||
<div>IntakeClient</div>
|
||||
{eligibleDraftGroups?.map((group) => <div key={group.id}>{group.name}</div>)}
|
||||
{eligibleSkuList?.map((sku) => (
|
||||
<div key={sku.id}>
|
||||
{sku.code}
|
||||
{' / '}
|
||||
{sku.skuFields?.skuCode}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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<string, unknown>, key: string) {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function normalizeSkuBatchFlowNos(value: unknown): Record<string, string> | 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.
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@ export interface ProductSku {
|
||||
price: number
|
||||
stock: number
|
||||
isActive: boolean
|
||||
attributes?: Record<string, string> | null
|
||||
legacyAttributes?: Record<string, string> | null
|
||||
displayTags?: string[]
|
||||
}
|
||||
|
||||
export interface ProductWithSkus {
|
||||
|
||||
@ -6,7 +6,8 @@ export interface ProductSku {
|
||||
price: number
|
||||
stock: number
|
||||
isActive: boolean
|
||||
attributes?: Record<string, string> | null
|
||||
legacyAttributes?: Record<string, string> | null
|
||||
displayTags?: string[]
|
||||
}
|
||||
|
||||
export interface ProductWithSkus {
|
||||
|
||||
@ -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<ScrewItem[]>(readScrewItems)
|
||||
const [items, setItems] = useState<ScrewItem[]>([])
|
||||
const [selectedLetter, setSelectedLetter] =
|
||||
useState<AccessoryLibraryLetter>('A')
|
||||
const [activeHeadType, setActiveHeadType] = useState('全部')
|
||||
@ -2214,18 +2230,68 @@ export default function AccessoryLibraryPanel({
|
||||
useState<ScrewFilterState>(EMPTY_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<ScrewFilterState>(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<HTMLInputElement>) {
|
||||
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 ? (
|
||||
<span>筛选后显示 {visibleItems.length} 条</span>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
serverError
|
||||
? 'text-rose-600'
|
||||
: serverNotice
|
||||
? 'text-amber-700'
|
||||
: 'text-emerald-700'
|
||||
}
|
||||
>
|
||||
{isServerLoading
|
||||
? '正在读取服务端资料库'
|
||||
: serverError || serverNotice || '已接入服务端资料库'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -2728,6 +2809,7 @@ export default function AccessoryLibraryPanel({
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal()}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增 {selectedLetter} 组
|
||||
@ -2933,6 +3015,7 @@ export default function AccessoryLibraryPanel({
|
||||
title="编辑"
|
||||
className={OPERATION_ICON_BUTTON_CLASS}
|
||||
onClick={() => openEditModal(item)}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@ -2942,6 +3025,7 @@ export default function AccessoryLibraryPanel({
|
||||
title="复制"
|
||||
className={OPERATION_ICON_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(item)}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@ -2952,6 +3036,7 @@ export default function AccessoryLibraryPanel({
|
||||
title="删除"
|
||||
className={DELETE_ICON_BUTTON_CLASS}
|
||||
onClick={() => removeItem(item)}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@ -3013,8 +3098,9 @@ export default function AccessoryLibraryPanel({
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
onClick={saveDraft}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
保存
|
||||
{isServerSaving ? '保存中' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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<string, unknown>, 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<Record<string, unknown>>
|
||||
}
|
||||
rows = (body.items ?? []).map((item, index) =>
|
||||
createReferenceLibraryRow(body.type ?? 'ACCESSORY', {
|
||||
...(item.payload && typeof item.payload === 'object'
|
||||
? (item.payload as Record<string, unknown>)
|
||||
: {}),
|
||||
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<string, unknown> } }>
|
||||
}
|
||||
}
|
||||
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel />)
|
||||
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(<AccessoryLibraryPanel canEditItems={false} />)
|
||||
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(<PackagingMaterialLibraryPanel />)
|
||||
|
||||
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(<PackagingMaterialLibraryPanel canEditItems={false} />)
|
||||
|
||||
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(<PackagingMaterialLibraryPanel />)
|
||||
|
||||
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(<PackagingMaterialLibraryPanel />)
|
||||
|
||||
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(<PackagingMaterialLibraryPanel />)
|
||||
|
||||
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(<PackagingMaterialLibraryPanel />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<AlphabetLibraryItem[]>(() => readLibraryItems(storageKey))
|
||||
const [items, setItems] = useState<AlphabetLibraryItem[]>([])
|
||||
const [isServerLoading, setIsServerLoading] = useState(true)
|
||||
const [isServerSaving, setIsServerSaving] = useState(false)
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [serverNotice, setServerNotice] = useState('')
|
||||
const [selectedLetter, setSelectedLetter] = useState<AlphabetLibraryLetter>('A')
|
||||
const [queryCode, setQueryCode] = useState('')
|
||||
const [queryName, setQueryName] = useState('')
|
||||
@ -168,15 +164,34 @@ export default function AlphabetLibraryPanel({
|
||||
const [previewItem, setPreviewItem] = useState<AlphabetLibraryItem | null>(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({
|
||||
<span>当前共 {items.length} 条{entityName}资料</span>
|
||||
<span>当前字母 {activeLetter} 共 {totalGroupedItems[activeLetter]?.length ?? 0} 条</span>
|
||||
{hasActiveQuery ? <span>筛选后显示 {filteredItems.length} 条</span> : null}
|
||||
<span className={serverError ? 'text-rose-600' : serverNotice ? 'text-amber-700' : 'text-emerald-700'}>
|
||||
{isServerLoading ? '正在读取服务端资料库' : serverError || serverNotice || '已接入服务端资料库'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -677,8 +724,9 @@ export default function AlphabetLibraryPanel({
|
||||
type="button"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={saveDraft}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
保存
|
||||
{isServerSaving ? '保存中' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<ReturnType<typeof listIntakeReferenceLibraryItems>>,
|
||||
) {
|
||||
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<EdgeBandingLibraryItem[]>([])
|
||||
const [categories, setCategories] = useState<EdgeBandingLibraryCategory[]>([])
|
||||
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<string | null>(
|
||||
initialItems[0]?.id ?? null,
|
||||
null,
|
||||
)
|
||||
const [selectedGroupKey, setSelectedGroupKey] = useState<string | null>(null)
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('view')
|
||||
const [editingItemId, setEditingItemId] = useState<string | null>(null)
|
||||
const [editorDraft, setEditorDraft] = useState<EdgeBandingLibraryDraft>(
|
||||
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<string | null>(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}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建封边
|
||||
@ -726,6 +864,12 @@ export default function EdgeBandingLibraryPanel({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-apple-label-secondary">
|
||||
<span>当前共 {normalizedItems.length} 条封边资料</span>
|
||||
<span className={serverError ? 'text-rose-600' : serverNotice ? 'text-amber-700' : 'text-emerald-700'}>
|
||||
{isServerLoading ? '正在读取服务端资料库' : serverError || serverNotice || '已接入服务端资料库'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 items-stretch gap-4 xl:grid-cols-[220px_minmax(0,1fr)_360px]">
|
||||
@ -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}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建分类
|
||||
@ -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}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除分类
|
||||
@ -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}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
@ -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)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
保存
|
||||
{isServerSaving ? '保存中' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<string, string | null> | null
|
||||
displaySummary?: Record<string, unknown> | null
|
||||
familySelection?: Record<string, unknown> | 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: [],
|
||||
|
||||
@ -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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<AlphabetLibraryPanel
|
||||
storageKey={PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY}
|
||||
changedEventName={PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT}
|
||||
entityName="包材"
|
||||
canEditItems={canEditItems}
|
||||
queryNamePlaceholder="例如:泡沫 / 纸箱 / 护角"
|
||||
serverLibraryType="PACKAGING_MATERIAL"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<SpuRule[]>(() => loadRules());
|
||||
const [rules, setRules] = useState<SpuRule[]>(() => getSeedRules());
|
||||
const [topCategoryQuery, setTopCategoryQuery] = useState("");
|
||||
const [secondCategoryQuery, setSecondCategoryQuery] = useState("");
|
||||
const [productCategoryQuery, setProductCategoryQuery] = useState("");
|
||||
const [summary, setSummary] = useState<ImportSummary | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<EditableSpuRule | null>(null);
|
||||
const [isServerLoading, setIsServerLoading] = useState(true);
|
||||
const [isServerSaving, setIsServerSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState("");
|
||||
const [serverNotice, setServerNotice] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) {
|
||||
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() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isServerSaving}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-apple-md border border-apple-separator bg-white px-3 text-xs font-apple-semibold text-apple-label-primary hover:bg-apple-bg-secondary"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@ -547,6 +590,7 @@ export function SpuRuleLibraryWorkspace() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={isServerSaving}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-apple-md border border-apple-separator bg-white px-3 text-xs font-apple-semibold text-apple-label-primary hover:bg-apple-bg-secondary"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@ -555,9 +599,11 @@ export function SpuRuleLibraryWorkspace() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isServerSaving) return;
|
||||
setEditingId(null);
|
||||
setDraft(emptyDraft);
|
||||
}}
|
||||
disabled={isServerSaving}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-apple-md bg-zinc-950 px-3 text-xs font-apple-semibold text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@ -566,6 +612,23 @@ export function SpuRuleLibraryWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-apple-label-secondary">
|
||||
<span>当前共 {rules.length} 条 SPU 规则</span>
|
||||
<span
|
||||
className={
|
||||
serverError
|
||||
? "text-rose-600"
|
||||
: serverNotice
|
||||
? "text-amber-700"
|
||||
: "text-emerald-700"
|
||||
}
|
||||
>
|
||||
{isServerLoading
|
||||
? "正在读取服务端规则库"
|
||||
: serverError || serverNotice || "已接入服务端规则库"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-apple-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
<FileSpreadsheet className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@ -678,6 +741,7 @@ export function SpuRuleLibraryWorkspace() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditor(rule)}
|
||||
disabled={isServerSaving}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-apple-md bg-zinc-950 px-3 text-xs font-apple-semibold text-white"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
|
||||
@ -4,15 +4,26 @@ import { useEffect, useMemo, useState, type InputHTMLAttributes } from 'react'
|
||||
import { ChevronDown, Copy, Plus, Save, Search, Trash2 } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
STEEL_FRAME_LIBRARY_CHANGED_EVENT,
|
||||
STEEL_FRAME_REFERENCE_CONFIG_CATEGORY,
|
||||
STEEL_FRAME_REFERENCE_ITEM_CATEGORY,
|
||||
STEEL_FRAME_REFERENCE_LIBRARY_TYPE,
|
||||
STEEL_FRAME_REFERENCE_PRICE_KEY,
|
||||
buildSteelFrameLibraryItems,
|
||||
buildSteelFrameMatchLabel,
|
||||
buildSteelFrameReferenceItemKey,
|
||||
buildSteelFrameSpecTitle,
|
||||
createEmptySteelFrameDraft,
|
||||
createSteelFrameLibraryItem,
|
||||
decodeSteelFrameReferenceLibraryRows,
|
||||
hydrateSteelFrameLibraryDraft,
|
||||
persistSteelFrameLibraryState,
|
||||
readSteelFrameLibraryState,
|
||||
STEEL_FRAME_TEMPLATE_REFERENCE_PRICE,
|
||||
type SteelFrameLibraryItem,
|
||||
} from '@/lib/steel-frame-library'
|
||||
import {
|
||||
listIntakeReferenceLibraryItems,
|
||||
replaceIntakeReferenceLibraryItems,
|
||||
} from '@/lib/intake-reference-library-client'
|
||||
|
||||
function normalizeText(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
@ -47,7 +58,7 @@ function formatSteelFrameMetricWithUnit(
|
||||
return `${prefix}${formattedValue}${unit}`
|
||||
}
|
||||
|
||||
type SteelFramePanelItem = ReturnType<typeof readSteelFrameLibraryState>['items'][number]
|
||||
type SteelFramePanelItem = SteelFrameLibraryItem
|
||||
type EditorMode = 'view' | 'create' | 'edit'
|
||||
|
||||
const STEEL_FRAME_CREATE_PRESETS = [
|
||||
@ -87,26 +98,73 @@ function SteelFrameField({
|
||||
)
|
||||
}
|
||||
|
||||
function buildSteelFrameReferenceLibraryRows(
|
||||
referenceSteelPrice: string,
|
||||
items: SteelFrameLibraryItem[],
|
||||
) {
|
||||
const normalizedReferenceSteelPrice =
|
||||
referenceSteelPrice.trim() || STEEL_FRAME_TEMPLATE_REFERENCE_PRICE
|
||||
const normalizedItems = buildSteelFrameLibraryItems(
|
||||
normalizedReferenceSteelPrice,
|
||||
items,
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
itemKey: STEEL_FRAME_REFERENCE_PRICE_KEY,
|
||||
code: STEEL_FRAME_REFERENCE_PRICE_KEY,
|
||||
name: '每吨钢管参考价',
|
||||
category: STEEL_FRAME_REFERENCE_CONFIG_CATEGORY,
|
||||
payload: {
|
||||
kind: STEEL_FRAME_REFERENCE_CONFIG_CATEGORY,
|
||||
referenceSteelPrice: normalizedReferenceSteelPrice,
|
||||
},
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
},
|
||||
...normalizedItems.map((item, index) => {
|
||||
const itemKey = buildSteelFrameReferenceItemKey(item)
|
||||
return {
|
||||
itemKey,
|
||||
code: itemKey,
|
||||
name: buildSteelFrameSpecTitle(item),
|
||||
category: STEEL_FRAME_REFERENCE_ITEM_CATEGORY,
|
||||
payload: {
|
||||
kind: STEEL_FRAME_REFERENCE_ITEM_CATEGORY,
|
||||
item,
|
||||
},
|
||||
sortOrder: index + 1,
|
||||
isActive: true,
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export default function SteelFrameLibraryPanel({
|
||||
canEditItems = true,
|
||||
}: {
|
||||
canEditItems?: boolean
|
||||
}) {
|
||||
const initialState = useMemo(() => readSteelFrameLibraryState(), [])
|
||||
const [referenceSteelPrice, setReferenceSteelPrice] = useState(initialState.referenceSteelPrice)
|
||||
const [savedItems, setSavedItems] = useState(initialState.items)
|
||||
const [referenceSteelPrice, setReferenceSteelPrice] = useState(
|
||||
STEEL_FRAME_TEMPLATE_REFERENCE_PRICE,
|
||||
)
|
||||
const [savedItems, setSavedItems] = useState<SteelFrameLibraryItem[]>([])
|
||||
const [queryName, setQueryName] = useState('')
|
||||
const [querySize, setQuerySize] = useState('')
|
||||
const [queryWallThickness, setQueryWallThickness] = useState('')
|
||||
const [appliedQueryName, setAppliedQueryName] = useState('')
|
||||
const [appliedQuerySize, setAppliedQuerySize] = useState('')
|
||||
const [appliedQueryWallThickness, setAppliedQueryWallThickness] = useState('')
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(initialState.items[0]?.id ?? null)
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('view')
|
||||
const [editingItemId, setEditingItemId] = useState<string | null>(null)
|
||||
const [editorDraft, setEditorDraft] = useState(createEmptySteelFrameDraft())
|
||||
const [panelMessage, setPanelMessage] = useState('')
|
||||
const [rulesExpanded, setRulesExpanded] = useState(false)
|
||||
const [isServerLoading, setIsServerLoading] = useState(true)
|
||||
const [isServerSaving, setIsServerSaving] = useState(false)
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [serverNotice, setServerNotice] = useState('')
|
||||
|
||||
const items = useMemo(
|
||||
() => buildSteelFrameLibraryItems(referenceSteelPrice, savedItems),
|
||||
@ -114,12 +172,69 @@ export default function SteelFrameLibraryPanel({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
persistSteelFrameLibraryState(referenceSteelPrice, savedItems)
|
||||
} catch {
|
||||
console.warn('钢架资料库本地缓存空间不足,当前修改暂未保存。')
|
||||
let isCancelled = false
|
||||
setIsServerLoading(true)
|
||||
setServerError('')
|
||||
setServerNotice('')
|
||||
|
||||
listIntakeReferenceLibraryItems(STEEL_FRAME_REFERENCE_LIBRARY_TYPE)
|
||||
.then((rows) => {
|
||||
if (isCancelled) return
|
||||
const snapshot = decodeSteelFrameReferenceLibraryRows(rows)
|
||||
const hasServerState =
|
||||
snapshot.items.length > 0 ||
|
||||
rows.some((row) => row.itemKey === STEEL_FRAME_REFERENCE_PRICE_KEY)
|
||||
|
||||
setReferenceSteelPrice(snapshot.referenceSteelPrice)
|
||||
setSavedItems(snapshot.items)
|
||||
setSelectedItemId(snapshot.items[0]?.id ?? null)
|
||||
setServerNotice(
|
||||
hasServerState ? '' : '服务端暂无钢架资料,新增后写入服务端资料库',
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isCancelled) return
|
||||
setServerError(error instanceof Error ? error.message : '钢架资料库读取失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsServerLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [referenceSteelPrice, savedItems])
|
||||
}, [])
|
||||
|
||||
async function commitSteelFrameLibrary(
|
||||
nextReferenceSteelPrice: string,
|
||||
nextItems: SteelFrameLibraryItem[],
|
||||
) {
|
||||
setIsServerSaving(true)
|
||||
setServerError('')
|
||||
setServerNotice('')
|
||||
|
||||
try {
|
||||
const savedRows = await replaceIntakeReferenceLibraryItems(
|
||||
STEEL_FRAME_REFERENCE_LIBRARY_TYPE,
|
||||
buildSteelFrameReferenceLibraryRows(nextReferenceSteelPrice, nextItems),
|
||||
)
|
||||
const snapshot = decodeSteelFrameReferenceLibraryRows(savedRows)
|
||||
setReferenceSteelPrice(snapshot.referenceSteelPrice)
|
||||
setSavedItems(snapshot.items)
|
||||
window.dispatchEvent(new Event(STEEL_FRAME_LIBRARY_CHANGED_EVENT))
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '钢架资料库保存失败'
|
||||
setServerError(message)
|
||||
setPanelMessage(message)
|
||||
return null
|
||||
} finally {
|
||||
setIsServerSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const nameKeyword = normalizeText(appliedQueryName)
|
||||
@ -197,7 +312,7 @@ export default function SteelFrameLibraryPanel({
|
||||
}
|
||||
|
||||
function handleStartCreate(presetName: string) {
|
||||
if (!canEditItems) return
|
||||
if (!canEditItems || isServerSaving) return
|
||||
|
||||
setEditorMode('create')
|
||||
setEditingItemId(null)
|
||||
@ -252,8 +367,8 @@ export default function SteelFrameLibraryPanel({
|
||||
setPanelMessage('')
|
||||
}
|
||||
|
||||
function handleSaveDraft() {
|
||||
if (!canEditItems) return
|
||||
async function handleSaveDraft() {
|
||||
if (!canEditItems || isServerSaving) return
|
||||
|
||||
if (!draftCanSave) {
|
||||
setPanelMessage(draftRequiresWidth ? '请先填完名称、长、宽、壁厚。' : '请先填完名称、长、壁厚。')
|
||||
@ -269,13 +384,12 @@ export default function SteelFrameLibraryPanel({
|
||||
id: editingItemId ?? undefined,
|
||||
})
|
||||
|
||||
setSavedItems((current) => {
|
||||
if (editingItemId) {
|
||||
return current.map((item) => (item.id === editingItemId ? nextItem : item))
|
||||
}
|
||||
const nextItems = editingItemId
|
||||
? savedItems.map((item) => (item.id === editingItemId ? nextItem : item))
|
||||
: [...savedItems, nextItem]
|
||||
const snapshot = await commitSteelFrameLibrary(referenceSteelPrice, nextItems)
|
||||
if (!snapshot) return
|
||||
|
||||
return [...current, nextItem]
|
||||
})
|
||||
setEditorMode('edit')
|
||||
setEditingItemId(nextItem.id)
|
||||
setEditorDraft(nextItem)
|
||||
@ -283,14 +397,19 @@ export default function SteelFrameLibraryPanel({
|
||||
setPanelMessage('钢架规格已保存。')
|
||||
}
|
||||
|
||||
function handleDeleteDraft() {
|
||||
if (!canEditItems) return
|
||||
async function handleDeleteDraft() {
|
||||
if (!canEditItems || isServerSaving) return
|
||||
|
||||
if (!editingItemId) return
|
||||
|
||||
const fallbackItem = items.find((item) => item.id !== editingItemId) ?? null
|
||||
|
||||
setSavedItems((current) => current.filter((item) => item.id !== editingItemId))
|
||||
const snapshot = await commitSteelFrameLibrary(
|
||||
referenceSteelPrice,
|
||||
savedItems.filter((item) => item.id !== editingItemId),
|
||||
)
|
||||
if (!snapshot) return
|
||||
|
||||
setEditorMode('view')
|
||||
setEditingItemId(null)
|
||||
setEditorDraft(createEmptySteelFrameDraft())
|
||||
@ -299,7 +418,7 @@ export default function SteelFrameLibraryPanel({
|
||||
}
|
||||
|
||||
function handleCopyDraft() {
|
||||
if (!canEditItems) return
|
||||
if (!canEditItems || isServerSaving) return
|
||||
if (!editingItemId) return
|
||||
|
||||
setEditorMode('create')
|
||||
@ -321,6 +440,11 @@ export default function SteelFrameLibraryPanel({
|
||||
setRulesExpanded(false)
|
||||
}
|
||||
|
||||
async function handleReferenceSteelPriceBlur() {
|
||||
if (!canEditItems || isServerSaving) return
|
||||
await commitSteelFrameLibrary(referenceSteelPrice, savedItems)
|
||||
}
|
||||
|
||||
const shouldShowEditor =
|
||||
canEditItems &&
|
||||
(editorMode === 'create' || (editorMode === 'edit' && Boolean(editingItemId)))
|
||||
@ -354,6 +478,7 @@ export default function SteelFrameLibraryPanel({
|
||||
onChange={(event) => {
|
||||
if (canEditItems) setReferenceSteelPrice(event.target.value)
|
||||
}}
|
||||
onBlur={handleReferenceSteelPriceBlur}
|
||||
placeholder="例如:4200"
|
||||
readOnly={!canEditItems}
|
||||
/>
|
||||
@ -369,6 +494,7 @@ export default function SteelFrameLibraryPanel({
|
||||
variant="gray"
|
||||
className={`${LIBRARY_PRIMARY_BUTTON_CLASS} h-9 whitespace-nowrap text-xs`}
|
||||
onClick={() => handleStartCreate(preset.name)}
|
||||
disabled={isServerSaving}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{preset.label}
|
||||
@ -386,6 +512,22 @@ export default function SteelFrameLibraryPanel({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-apple-label-secondary">
|
||||
<span>当前共 {items.length} 条钢架规格</span>
|
||||
<span
|
||||
className={
|
||||
serverError
|
||||
? 'text-rose-600'
|
||||
: serverNotice
|
||||
? 'text-amber-700'
|
||||
: 'text-emerald-700'
|
||||
}
|
||||
>
|
||||
{isServerLoading
|
||||
? '正在读取服务端资料库'
|
||||
: serverError || serverNotice || '已接入服务端资料库'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[340px_minmax(0,1fr)]">
|
||||
@ -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}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
@ -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}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制
|
||||
@ -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}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存
|
||||
{isServerSaving ? '保存中' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, unknown> | 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<IntegratedSeriesSkuDefault>((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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
<NonIntegratedDetailClient
|
||||
mode="create"
|
||||
@ -577,13 +602,12 @@ 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]?.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(
|
||||
<NonIntegratedDetailClient
|
||||
mode="create"
|
||||
@ -784,14 +821,18 @@ describe("NonIntegratedDetailClient", () => {
|
||||
|
||||
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(<NonIntegratedDetailClient mode="create" />);
|
||||
|
||||
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(<NonIntegratedDetailClient mode="create" />);
|
||||
|
||||
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(
|
||||
<NonIntegratedDetailClient mode="edit" productId="DNZ0001-sidebar-order" />,
|
||||
@ -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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -1811,9 +1825,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<NonIntegratedDetailClient mode="edit" productId="DNZ0001-draft-project" />,
|
||||
@ -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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -2539,9 +2547,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -5645,9 +5646,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -5727,9 +5725,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -6808,9 +6803,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
"/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(
|
||||
<NonIntegratedDetailClient mode="edit" productId="DNZ0001-pdf-preview" />,
|
||||
@ -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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -6899,9 +6888,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -6938,9 +6924,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -6980,9 +6963,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
);
|
||||
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7058,15 +7036,12 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7086,9 +7061,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
).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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7145,9 +7117,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7270,9 +7236,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient mode="edit" productId="SZ0230-brand-locked" />,
|
||||
@ -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(<NonIntegratedDetailClient mode="edit" productId="DNZ0001-1" />);
|
||||
|
||||
@ -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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7409,14 +7366,11 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7440,14 +7394,11 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7482,16 +7433,13 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7605,9 +7553,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7704,9 +7649,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7827,9 +7769,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -7967,9 +7906,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -8123,9 +8059,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -8247,10 +8180,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([product]),
|
||||
);
|
||||
replaceNonIntegratedProducts([product]);
|
||||
|
||||
const deniedRender = render(
|
||||
<NonIntegratedDetailClient
|
||||
@ -8309,9 +8239,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -8367,9 +8294,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
@ -8572,9 +8496,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
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(
|
||||
<NonIntegratedDetailClient
|
||||
|
||||
@ -91,9 +91,16 @@ import {
|
||||
findNonIntegratedBomSteelFrameOption,
|
||||
type NonIntegratedBomSteelFrameOption,
|
||||
} from "@/lib/non-integrated-bom-steel-materials";
|
||||
import {
|
||||
STEEL_FRAME_LIBRARY_CHANGED_EVENT,
|
||||
STEEL_FRAME_REFERENCE_LIBRARY_TYPE,
|
||||
decodeSteelFrameReferenceLibraryRows,
|
||||
} from "@/lib/steel-frame-library";
|
||||
import {
|
||||
EDGE_BANDING_LIBRARY_CHANGED_EVENT,
|
||||
EDGE_BANDING_REFERENCE_LIBRARY_TYPE,
|
||||
buildNonIntegratedBomEdgeBandingOptions,
|
||||
decodeEdgeBandingReferenceLibraryItems,
|
||||
filterNonIntegratedBomEdgeBandingOptions,
|
||||
parseBoardThicknessMmFromText,
|
||||
type NonIntegratedBomEdgeBandingOption,
|
||||
@ -104,16 +111,21 @@ import {
|
||||
} from "@/lib/non-integrated-category-options";
|
||||
import {
|
||||
ACCESSORY_LIBRARY_CHANGED_EVENT,
|
||||
ACCESSORY_REFERENCE_LIBRARY_TYPE,
|
||||
applyNonIntegratedBomAccessoryMetrics,
|
||||
buildNonIntegratedBomAccessoryOptions,
|
||||
decodeAccessoryReferenceLibraryRows,
|
||||
findNonIntegratedBomAccessoryOption,
|
||||
type NonIntegratedBomAccessoryOption,
|
||||
type NonIntegratedBomAccessoryMatchKey,
|
||||
} from "@/lib/accessory-library";
|
||||
import { listIntakeReferenceLibraryItems } from "@/lib/intake-reference-library-client";
|
||||
import {
|
||||
PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT,
|
||||
PACKAGING_MATERIAL_REFERENCE_LIBRARY_TYPE,
|
||||
applyNonIntegratedBomPackagingMaterialMetrics,
|
||||
buildNonIntegratedBomPackagingMaterialOptions,
|
||||
decodePackagingMaterialReferenceLibraryRows,
|
||||
findNonIntegratedBomPackagingMaterialOption,
|
||||
type NonIntegratedBomPackagingMaterialMatchKey,
|
||||
type NonIntegratedBomPackagingMaterialOption,
|
||||
@ -158,7 +170,7 @@ import RawMaterialLibraryPanel from "@/app/intake/RawMaterialLibraryPanel";
|
||||
import {
|
||||
SPU_RULE_LIBRARY_UPDATED_EVENT,
|
||||
getEstimatedSpuAssignment,
|
||||
reserveSpuAssignmentForProductCategory,
|
||||
syncSpuRuleLibraryRulesFromServer,
|
||||
} from "../spuRuleLibraryLogic";
|
||||
import type { SeriesDraftSkuFields } from "../seriesComponentDetails";
|
||||
import ApprovalWorkflowWorkspace from "./ApprovalWorkflowWorkspace";
|
||||
@ -11482,8 +11494,25 @@ export default function NonIntegratedDetailClient({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<BasicField
|
||||
label="年份"
|
||||
value={
|
||||
openingReference.year ||
|
||||
formValues.year ||
|
||||
getAutoProductYear()
|
||||
}
|
||||
readOnly
|
||||
compact
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
<>
|
||||
<BasicField
|
||||
label="SPU"
|
||||
value={seriesCreateEstimatedSpuLabel}
|
||||
readOnly
|
||||
compact
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
<BasicField
|
||||
label="年份"
|
||||
value={
|
||||
openingReference.year ||
|
||||
formValues.year ||
|
||||
getAutoProductYear()
|
||||
}
|
||||
readOnly
|
||||
compact
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AdaptableBrandField
|
||||
value={formValues.adaptableBrands}
|
||||
|
||||
@ -258,7 +258,10 @@ export async function getSharedNonIntegratedProductById(id: string) {
|
||||
|
||||
export async function saveSharedNonIntegratedProduct(
|
||||
product: NonIntegratedProductRecord,
|
||||
options: { workspaceCategory?: string | null } = {},
|
||||
options: {
|
||||
workspaceCategory?: string | null
|
||||
formalSpuAssignmentProductCategory?: string | null
|
||||
} = {},
|
||||
) {
|
||||
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(product.id)}`, {
|
||||
method: 'PUT',
|
||||
@ -270,6 +273,12 @@ export async function saveSharedNonIntegratedProduct(
|
||||
...(options.workspaceCategory
|
||||
? { workspaceCategory: options.workspaceCategory }
|
||||
: {}),
|
||||
...(options.formalSpuAssignmentProductCategory
|
||||
? {
|
||||
formalSpuAssignmentProductCategory:
|
||||
options.formalSpuAssignmentProductCategory,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@ -21,13 +21,10 @@ import {
|
||||
} from "@/lib/asset-variant-render-config";
|
||||
import { normalizeNonIntegratedAssetUrl } from "./nonIntegratedAssetUrl";
|
||||
|
||||
export const NON_INTEGRATED_STORAGE_KEY = "intake.nonIntegratedProducts";
|
||||
export const NON_INTEGRATED_PRODUCTS_UPDATED =
|
||||
"intake:nonIntegratedProductsUpdated";
|
||||
export const NON_INTEGRATED_DUPLICATE_SPU_MESSAGE =
|
||||
"SPU 已存在,不能重复新建。删除原记录后才可以重新新建。";
|
||||
export const NON_INTEGRATED_STORAGE_LIMIT_MESSAGE =
|
||||
"本地测试存储空间已满,当前上传文件过大或过多,保存失败。";
|
||||
export const NON_INTEGRATED_DEFAULT_BRAND = "其他品牌-普通大货";
|
||||
|
||||
export const NON_INTEGRATED_BRAND_OPTIONS = [
|
||||
@ -830,9 +827,18 @@ export function getNonIntegratedProductDisplayName(
|
||||
);
|
||||
}
|
||||
|
||||
function canUseStorage() {
|
||||
type NonIntegratedLocalCompatibilityStorageGlobal = typeof globalThis & {
|
||||
__NON_INTEGRATED_LOCAL_COMPATIBILITY_STORAGE__?: boolean;
|
||||
};
|
||||
|
||||
let nonIntegratedRuntimeProducts: NonIntegratedProductRecord[] = [];
|
||||
|
||||
function canUseRuntimeCompatibilityStore() {
|
||||
if (process.env.NODE_ENV === "test") return true;
|
||||
|
||||
return (
|
||||
typeof window !== "undefined" && typeof window.localStorage !== "undefined"
|
||||
(globalThis as NonIntegratedLocalCompatibilityStorageGlobal)
|
||||
.__NON_INTEGRATED_LOCAL_COMPATIBILITY_STORAGE__ === true
|
||||
);
|
||||
}
|
||||
|
||||
@ -1310,7 +1316,7 @@ function normalizeSkuItem(
|
||||
image: normalizeNonIntegratedAssetUrl(normalizeString(item.image)),
|
||||
imageName: normalizeString(item.imageName),
|
||||
model: normalizeString(item.model),
|
||||
skuCode: normalizeString(item.skuCode),
|
||||
skuCode: normalizeString(item.skuCode).trim().toUpperCase(),
|
||||
setCount:
|
||||
typeof item.setCount === "string"
|
||||
? item.setCount
|
||||
@ -1318,7 +1324,7 @@ function normalizeSkuItem(
|
||||
? String(item.setCount)
|
||||
: "1",
|
||||
specName: normalizeString(item.specName),
|
||||
barcode: normalizeString(item.barcode),
|
||||
barcode: normalizeString(item.barcode).trim().toUpperCase(),
|
||||
size: normalizeString(item.size),
|
||||
color: normalizeString(item.color),
|
||||
material: normalizeString(item.material),
|
||||
@ -2259,22 +2265,12 @@ export function normalizeNonIntegratedProductRecord(
|
||||
}
|
||||
|
||||
export function listNonIntegratedProducts(): NonIntegratedProductRecord[] {
|
||||
if (!canUseStorage()) return [];
|
||||
if (!canUseRuntimeCompatibilityStore()) return [];
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY);
|
||||
if (!rawValue) return [];
|
||||
|
||||
const parsedValue = JSON.parse(rawValue);
|
||||
if (!Array.isArray(parsedValue)) return [];
|
||||
|
||||
return parsedValue
|
||||
.map((item) => 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);
|
||||
|
||||
@ -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<string, unknown> | null
|
||||
familySelection?: Record<string, unknown> | 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 =
|
||||
|
||||
@ -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<string>();
|
||||
const seenGroupKeys = new Set<string>();
|
||||
|
||||
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[],
|
||||
|
||||
@ -31,6 +31,7 @@ export type SeriesDraftSkuFields = {
|
||||
};
|
||||
|
||||
type PreSkuComponentDetailSource = {
|
||||
basicInfo?: Partial<Record<keyof SeriesDraftSkuFields | "formalSkuCode", string | null>> | null;
|
||||
displaySummary?: Record<string, unknown> | null;
|
||||
familySelection?: Record<string, unknown> | 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<PreSkuComponentDetailSource["basicInfo"]>];
|
||||
const normalizedBasicInfoValue = normalizePreSkuTextValue(basicInfoValue);
|
||||
if (normalizedBasicInfoValue) return normalizedBasicInfoValue;
|
||||
|
||||
const summaryValue = preSku.displaySummary?.[key];
|
||||
const normalizedSummaryValue = normalizePreSkuTextValue(summaryValue);
|
||||
if (normalizedSummaryValue) return normalizedSummaryValue;
|
||||
|
||||
@ -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<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<ReturnType<typeof listIntakeReferenceLibraryItems>>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@ -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<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
images: string[]
|
||||
modelUrl: string | null
|
||||
isActive: boolean
|
||||
@ -43,6 +45,15 @@ interface SKUData {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface SKUUpdatePayload extends Omit<SKUData, 'displayTags'> {
|
||||
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}
|
||||
</h4>
|
||||
{/* 规格属性展示 */}
|
||||
{sku.attributes && Object.keys(sku.attributes).length > 0 && (
|
||||
{sku.displayTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{Object.entries(sku.attributes).map(([key, value]) => (
|
||||
{sku.displayTags.map((value) => (
|
||||
<span
|
||||
key={key}
|
||||
key={value}
|
||||
className="text-apple-sm font-apple-system tracking-apple-tighter px-2 py-0.5 rounded-apple-sm"
|
||||
style={{
|
||||
backgroundColor: 'rgba(84, 96, 103, 0.1)',
|
||||
|
||||
@ -2,6 +2,15 @@ import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { canViewProduct } from '@/lib/permissions'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import {
|
||||
buildSkuDisplayTags,
|
||||
toLegacySkuDisplayAttributes,
|
||||
} from '@/lib/sku-display-tags'
|
||||
import {
|
||||
hasSkuCadCompatibilityData,
|
||||
readSkuCadExcludedHolesCount,
|
||||
readSkuCadExcludedHolesJson,
|
||||
} from '@/lib/sku-cad-data'
|
||||
import ProductDetailClient from './ProductDetailClient'
|
||||
|
||||
interface ProductPageProps {
|
||||
@ -114,6 +123,13 @@ export default async function ProductDetailPage({ params }: ProductPageProps) {
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
assetProfile: {
|
||||
select: {
|
||||
specName: true,
|
||||
color: true,
|
||||
material: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
@ -125,10 +141,7 @@ export default async function ProductDetailPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
const skuStructureEntry =
|
||||
product.skus.find((sku) => {
|
||||
const attrs = (sku.attributes || {}) as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
const display = Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key, value]) => !key.startsWith('_') && typeof value === 'string')
|
||||
) as Record<string, string>
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
})(),
|
||||
cadExcludedHolesJson: (() => {
|
||||
const attrs = (sku.attributes || {}) as Record<string, unknown>
|
||||
const v = attrs['_cadExcludedHoles']
|
||||
return typeof v === 'string' && v.trim().length > 0 ? v : null
|
||||
})(),
|
||||
cadExcludedHolesCount: (() => {
|
||||
const attrs = (sku.attributes || {}) as Record<string, unknown>
|
||||
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(),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -14,7 +14,7 @@ export interface SKUData {
|
||||
price: number
|
||||
costPrice: number | null
|
||||
stock: number
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
cadExcludedHolesJson?: string | null
|
||||
cadExcludedHolesCount?: number
|
||||
images: string[]
|
||||
|
||||
@ -94,7 +94,8 @@ interface SKUFullInfo {
|
||||
costPrice: number | null
|
||||
stock: number
|
||||
isActive: boolean
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
images: string[]
|
||||
modelUrl: string | null
|
||||
}
|
||||
@ -103,7 +104,8 @@ interface SKUBrief {
|
||||
id: string
|
||||
skuCode: string
|
||||
price: number
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
}
|
||||
|
||||
interface SKUDetailClientProps {
|
||||
|
||||
@ -12,7 +12,8 @@ interface SKUReadOnlySidebarProps {
|
||||
id: string
|
||||
skuCode: string
|
||||
isActive: boolean
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
price: number
|
||||
costPrice: number | null
|
||||
stock: number
|
||||
@ -99,11 +100,11 @@ export function SKUReadOnlySidebar({
|
||||
{sku.isActive ? '在售' : '停售'}
|
||||
</Badge>
|
||||
</div>
|
||||
{sku.attributes && Object.keys(sku.attributes).length > 0 && (
|
||||
{sku.displayTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{Object.entries(sku.attributes).map(([key, value]) => (
|
||||
{sku.displayTags.map((value) => (
|
||||
<span
|
||||
key={key}
|
||||
key={value}
|
||||
className="px-2 py-0.5 rounded-sm text-apple-xs font-apple-system"
|
||||
style={{ backgroundColor: 'rgba(84, 96, 103, 0.1)', color: 'var(--color-blue)' }}
|
||||
>
|
||||
|
||||
@ -9,7 +9,8 @@ interface SKUSwitchDrawerProps {
|
||||
id: string
|
||||
skuCode: string
|
||||
price: number
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
}>
|
||||
currentSkuId: string
|
||||
onClose: () => void
|
||||
@ -68,11 +69,11 @@ export function SKUSwitchDrawer({
|
||||
¥{skuItem.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{skuItem.attributes && Object.keys(skuItem.attributes).length > 0 && (
|
||||
{skuItem.displayTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(skuItem.attributes).map(([key, value]) => (
|
||||
{skuItem.displayTags.map((value) => (
|
||||
<span
|
||||
key={key}
|
||||
key={value}
|
||||
className="px-1.5 py-0.5 rounded-sm text-apple-xs"
|
||||
style={{ backgroundColor: 'rgba(84, 96, 103, 0.1)', color: 'var(--color-blue)' }}
|
||||
>
|
||||
|
||||
@ -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<string, unknown> | 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<string, unknown> | 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}
|
||||
|
||||
@ -18,7 +18,8 @@ export interface SKUFullInfo {
|
||||
costPrice: number | null
|
||||
stock: number
|
||||
isActive: boolean
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
images: string[]
|
||||
modelUrl: string | null
|
||||
}
|
||||
@ -27,7 +28,8 @@ export interface SKUBrief {
|
||||
id: string
|
||||
skuCode: string
|
||||
price: number
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
displayTags: string[]
|
||||
}
|
||||
|
||||
export interface SKUDetailClientProps {
|
||||
|
||||
@ -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<string, string> | null
|
||||
assetProfile: PublicSkuAssetProfile | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
}
|
||||
|
||||
// SKU 简要信息(用于切换)
|
||||
interface SKUBrief {
|
||||
id: string
|
||||
skuCode: string
|
||||
name: string | null
|
||||
price: number
|
||||
attributes: Record<string, string> | null
|
||||
assetProfile: PublicSkuAssetProfile | null
|
||||
legacyAttributes: Record<string, string> | 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)}
|
||||
</span>
|
||||
</div>
|
||||
{sku.attributes && Object.keys(sku.attributes).length > 0 && (
|
||||
{getSkuBadges(sku).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(sku.attributes).map(([key, value]) => (
|
||||
{getSkuBadges(sku).map((value) => (
|
||||
<span
|
||||
key={key}
|
||||
key={value}
|
||||
className="px-2 py-0.5 rounded-apple-sm text-apple-xs font-apple-system bg-apple-blue/20 text-apple-blue"
|
||||
>
|
||||
{value}
|
||||
@ -544,11 +572,11 @@ export function SharePageClient({ product, sku, allSkus, steps, panels, shareTok
|
||||
¥{s.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{s.attributes && Object.keys(s.attributes).length > 0 && (
|
||||
{getSkuBadges(s).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(s.attributes).map(([key, value]) => (
|
||||
{getSkuBadges(s).map((value) => (
|
||||
<span
|
||||
key={key}
|
||||
key={value}
|
||||
className="px-1.5 py-0.5 rounded-apple-sm text-apple-xs bg-apple-blue/20 text-apple-blue"
|
||||
>
|
||||
{value}
|
||||
|
||||
@ -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<string, string> | 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<string, string> | null,
|
||||
assetProfile: sku.assetProfile,
|
||||
legacyAttributes: sku.legacyAttributes,
|
||||
}))}
|
||||
steps={product.assemblySteps.map((step) => ({
|
||||
id: `share-step-${step.order}`,
|
||||
|
||||
@ -40,7 +40,7 @@ interface TrashSKU {
|
||||
expiresAt: string
|
||||
daysRemaining: number
|
||||
price: number
|
||||
attributes: Record<string, unknown> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
}
|
||||
|
||||
interface TrashAssetFamily {
|
||||
|
||||
@ -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<typeof detail> => 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
|
||||
|
||||
@ -22,7 +22,7 @@ interface SKUData {
|
||||
price: number
|
||||
costPrice: number | null
|
||||
stock: number
|
||||
attributes: Record<string, string> | null
|
||||
legacyAttributes: Record<string, string> | 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<string, string> = {}
|
||||
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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 规格属性 */}
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-4 fade-in duration-500 delay-150">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title3 className="text-apple-label-primary">规格属性</Title3>
|
||||
<Button
|
||||
variant="tinted"
|
||||
size="small"
|
||||
onClick={handleAddAttribute}
|
||||
>
|
||||
+ 添加属性
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Caption1 className="text-apple-label-tertiary">
|
||||
定义 SKU 的规格属性,如颜色、尺寸等
|
||||
</Caption1>
|
||||
|
||||
<div className="space-y-3">
|
||||
{formData.attributeKeys.map((key, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => handleAttributeChange(index, 'key', e.target.value)}
|
||||
placeholder="属性名(如:颜色)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={formData.attributeValues[index] || ''}
|
||||
onChange={(e) => handleAttributeChange(index, 'value', e.target.value)}
|
||||
placeholder="属性值(如:红色)"
|
||||
className="flex-1"
|
||||
/>
|
||||
{formData.attributeKeys.length > 1 && (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={() => handleRemoveAttribute(index)}
|
||||
className="text-apple-red hover:bg-apple-red/10 p-2"
|
||||
ariaLabel="删除属性"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 负责人信息(只读) */}
|
||||
{sku.owner && (
|
||||
<div className="p-4 rounded-apple-lg animate-in slide-in-from-bottom-4 fade-in duration-500 delay-200" style={{ backgroundColor: 'var(--color-background-secondary)' }}>
|
||||
|
||||
@ -46,7 +46,8 @@ interface ProductListItemProps {
|
||||
price: number
|
||||
stock: number
|
||||
isActive: boolean
|
||||
attributes?: Record<string, string> | null
|
||||
legacyAttributes?: Record<string, string> | null
|
||||
displayTags?: string[]
|
||||
}>
|
||||
}
|
||||
onEdit?: (product: ProductListItemProps['product']) => void
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<NonIntegratedBomAccessoryOption>((item) => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<EdgeBandingLibraryItem, 'spec' | 'compatibleThicknessMm'>,
|
||||
) {
|
||||
const thickness = formatEdgeBandingNumber(
|
||||
parseBoardThicknessMmFromText(item.compatibleThicknessMm),
|
||||
)
|
||||
return [
|
||||
'ITEM',
|
||||
normalizeReferenceKeyPart(item.spec),
|
||||
normalizeReferenceKeyPart(thickness || item.compatibleThicknessMm),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function createEdgeBandingLibraryItem(
|
||||
draft: Partial<EdgeBandingLibraryDraft>,
|
||||
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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<NonIntegratedBomEdgeBandingOption | null>((item) => {
|
||||
|
||||
@ -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<SteelLikeRow, 'partName' | 'materia
|
||||
)
|
||||
}
|
||||
|
||||
export function buildNonIntegratedBomSteelFrameOptions() {
|
||||
const libraryState = readSteelFrameLibraryState()
|
||||
|
||||
export function buildNonIntegratedBomSteelFrameOptions(
|
||||
libraryState: SteelFrameLibraryState = createSteelFrameTemplateState(),
|
||||
) {
|
||||
return libraryState.items
|
||||
.map<NonIntegratedBomSteelFrameOption>((item) => {
|
||||
const displayName = buildSteelFrameMatchLabel(item)
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
@ -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, '')
|
||||
}
|
||||
|
||||
@ -258,13 +258,53 @@ export interface PreviewHoleOverlayState {
|
||||
panelLabelByPanelId: Record<string, string>
|
||||
}
|
||||
|
||||
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<string, keyof PreSkuBasicInfoTextSource> = {
|
||||
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<string, unknown> | null
|
||||
familySelection?: Record<string, unknown> | 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()
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<NonIntegratedBomPackagingMaterialOption>((item) => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -843,6 +843,36 @@ export function readSkuCadDataFromAttributes(attributes: unknown): PersistedSkuC
|
||||
})
|
||||
}
|
||||
|
||||
function readSkuCadCompatibilityAttributes(attributes: unknown): Record<string, unknown> {
|
||||
return attributes && typeof attributes === 'object' && !Array.isArray(attributes)
|
||||
? (attributes as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
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<string, unknown> | null): Record<string, string> | null {
|
||||
if (!attributes) {
|
||||
return null
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<SteelFrameLibraryItem, 'name' | 'length' | 'width' | 'wallThickness'>,
|
||||
) {
|
||||
return [
|
||||
'ITEM',
|
||||
normalizeReferenceKeyPart(item.name),
|
||||
normalizeReferenceKeyPart(item.length),
|
||||
normalizeReferenceKeyPart(item.width || 'NO-WIDTH'),
|
||||
normalizeReferenceKeyPart(item.wallThickness),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function calculateSteelFrameWeightPerBar(
|
||||
draft: Pick<SteelFrameLibraryDraft, 'name' | 'length' | 'width' | 'wallThickness'>,
|
||||
) {
|
||||
@ -192,38 +219,6 @@ export function createSteelFrameLibraryItem(
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedSteelFrameLibraryValue(): Partial<SteelFrameLibraryPersistedState> {
|
||||
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<SteelFrameLibraryPersistedState>,
|
||||
) {
|
||||
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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<SteelFrameLibraryItem, 'length' | 'width'>) {
|
||||
const normalizedLength = item.length.trim()
|
||||
const normalizedWidth = item.width.trim()
|
||||
|
||||
@ -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<string, string>
|
||||
name?: string | null
|
||||
legacyAttributes: Record<string, string> | 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<string, string>
|
||||
name?: string | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
assetProfile: ProductSkuAssetProfileDTO | null
|
||||
displayTags: string[]
|
||||
price: number
|
||||
costPrice?: number | null
|
||||
stock: number
|
||||
@ -160,14 +186,47 @@ export function toUserBriefDTO(user: Pick<User, 'id' | 'name' | 'avatar'>): 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<string, string>,
|
||||
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<User, 'id' | 'name' | 'avatar'> }
|
||||
sku: ProductSKU & {
|
||||
owner: Pick<User, 'id' | 'name' | 'avatar'>
|
||||
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<string, string>,
|
||||
name: sku.name,
|
||||
legacyAttributes,
|
||||
assetProfile,
|
||||
displayTags: buildSkuDisplayTags({ assetProfile, legacyAttributes }),
|
||||
price: Number(sku.price),
|
||||
costPrice: sku.costPrice ? Number(sku.costPrice) : null,
|
||||
stock: sku.stock,
|
||||
|
||||
@ -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<string, string>
|
||||
legacyAttributes: Record<string, string> | 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<string, unknown> | null
|
||||
legacyAttributes: Record<string, string> | null
|
||||
owner: UserBriefDTO
|
||||
product: {
|
||||
id: string
|
||||
@ -51,7 +60,10 @@ export interface SKUDetailDTO {
|
||||
id: string
|
||||
productId: string
|
||||
skuCode: string
|
||||
attributes: Record<string, string>
|
||||
name?: string | null
|
||||
legacyAttributes: Record<string, string> | 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<string, string>
|
||||
price: number
|
||||
costPrice?: number
|
||||
@ -97,6 +110,7 @@ export interface CreateSKUInput {
|
||||
*/
|
||||
export interface UpdateSKUInput {
|
||||
skuCode?: string
|
||||
name?: string
|
||||
attributes?: Record<string, string>
|
||||
price?: number
|
||||
costPrice?: number | null
|
||||
@ -123,15 +137,22 @@ export function toSKUListItemDTO(
|
||||
sku: ProductSKU & {
|
||||
owner: Pick<User, 'id' | 'name' | 'avatar'>
|
||||
product: Pick<Product, 'name' | 'modelNumber'>
|
||||
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<string, string>,
|
||||
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<User, 'id' | 'name' | 'avatar'>
|
||||
assetProfile?: ProductSkuAssetProfile | null
|
||||
product: Pick<Product, 'id' | 'modelNumber' | 'name' | 'modelUrl'> & {
|
||||
category: Pick<ProductCategory, 'id' | 'name'> | 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<string, string>,
|
||||
name: sku.name,
|
||||
legacyAttributes,
|
||||
assetProfile,
|
||||
displayTags: buildSkuDisplayTags({ assetProfile, legacyAttributes }),
|
||||
price: Number(sku.price),
|
||||
costPrice: sku.costPrice ? Number(sku.costPrice) : null,
|
||||
stock: sku.stock,
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, unknown>) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<AssetFamily | null> {
|
||||
return this.prisma.assetFamily.findUnique({ where: { code } })
|
||||
return this.prisma.assetFamily.findUnique({ where: { code: normalizeCoreIdentity(code) } })
|
||||
}
|
||||
|
||||
async findWithRelationsById(id: string): Promise<AssetFamilyWithRelations | null> {
|
||||
@ -364,7 +365,7 @@ class AssetSeriesRepositoryClass extends BaseRepository<
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<AssetSeries | null> {
|
||||
return this.prisma.assetSeries.findUnique({ where: { code } })
|
||||
return this.prisma.assetSeries.findUnique({ where: { code: normalizeCoreIdentity(code) } })
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AssetSeries | null> {
|
||||
@ -400,7 +401,7 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
async findByCode(code: string): Promise<AssetVariant | null> {
|
||||
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<AssetVariant | null> {
|
||||
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<Substrate | null> {
|
||||
return this.prisma.substrate.findUnique({ where: { code } })
|
||||
return this.prisma.substrate.findUnique({ where: { code: normalizeCoreIdentity(code) } })
|
||||
}
|
||||
|
||||
async findByIdWithRelations(id: string): Promise<SubstrateWithRelations | null> {
|
||||
|
||||
@ -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<CommonComponent | null> {
|
||||
return this.prisma.commonComponent.findUnique({ where: { code } })
|
||||
return this.prisma.commonComponent.findUnique({ where: { code: normalizeCoreIdentity(code) } })
|
||||
}
|
||||
|
||||
async codeExists(code: string, excludeId?: string): Promise<boolean> {
|
||||
const count = await this.prisma.commonComponent.count({
|
||||
where: {
|
||||
code,
|
||||
code: normalizeCoreIdentity(code),
|
||||
id: excludeId ? { not: excludeId } : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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<PreSkuWithRelations>
|
||||
}
|
||||
|
||||
async findByDisplaySummaryField(
|
||||
async upsertBasicInfo(
|
||||
preSkuId: string,
|
||||
data: Omit<Prisma.PreSkuBasicInfoUncheckedCreateInput, 'id' | 'preSkuId'>,
|
||||
): Promise<PreSkuWithRelations> {
|
||||
return this.prisma.preSku.update({
|
||||
where: { id: preSkuId },
|
||||
data: {
|
||||
basicInfo: {
|
||||
upsert: {
|
||||
create: data,
|
||||
update: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
basicInfo: true,
|
||||
packagingPlan: {
|
||||
include: {
|
||||
packagedItems: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Promise<PreSkuWithRelations>
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
excludeId?: string,
|
||||
): Promise<Array<{ id: string; defaultName: string; customName: string | null }>> {
|
||||
@ -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,
|
||||
|
||||
@ -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<EmployeeDirectory> {
|
||||
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<string, { id: string; fullPath: string }>()
|
||||
const activeEmployeeIdentityKeys = new Set<string>()
|
||||
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) {
|
||||
|
||||
@ -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<Feedback> {
|
||||
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<Feedback> {
|
||||
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<Record<string, unknown>>) || []
|
||||
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<Feedback> {
|
||||
const normalizedId = normalizeRequiredText(id, '反馈 ID 不能为空')
|
||||
const normalizedAssigneeId = normalizeRequiredText(assigneeId, '处理人不能为空')
|
||||
return this.prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { assigneeId },
|
||||
where: { id: normalizedId },
|
||||
data: { assigneeId: normalizedAssigneeId },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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<Hardware | null> {
|
||||
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<boolean> {
|
||||
const count = await this.prisma.hardware.count({
|
||||
where: {
|
||||
code,
|
||||
code: normalizeCoreIdentity(code),
|
||||
id: excludeId ? { not: excludeId } : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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<ActivityLog> {
|
||||
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,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user