chore(data-foundation): sync xuxiake mainline and stabilize master data

This commit is contained in:
WuYongJie 2026-06-02 10:57:38 +08:00
parent 35fed448cf
commit f0d3f5daae
176 changed files with 13356 additions and 3679 deletions

View File

@ -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/

View File

@ -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` 和疑似生产库名。

View File

@ -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`。

View File

@ -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:页面没变化

View File

@ -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

View File

@ -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",

View File

@ -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 }
: {}),
},
})

View File

@ -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");

View File

@ -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 $$;

View File

@ -113,6 +113,7 @@ model ProductSKU {
// SKU标识
skuCode String @unique // SKU编码,如 "A001-RED-3S"
name String? // SKU名称
// 规格属性(灵活的JSON结构)
// 示例: { "color": "红色", "size": "三人座", "material": "布艺" }
@ -138,6 +139,10 @@ model ProductSKU {
// 状态
isActive Boolean @default(true) // 是否上架
originalSkuCode String?
deletedCodeAt DateTime?
deletedByProductId String?
productDeletedAt DateTime?
// SKU 级装配步骤(与产品级步骤隔离)
assemblySteps AssemblyStep[]
@ -150,6 +155,7 @@ model ProductSKU {
sourceAssetVariantId String?
sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull)
sourceAssetVariantIds String[] @default([])
assetProfile ProductSkuAssetProfile?
warehouseLocationId String?
warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull)
packagedItems PackagedItem[] @relation("PackagedItemSku")
@ -162,6 +168,7 @@ model ProductSKU {
@@index([productId])
@@index([skuCode])
@@index([name])
@@index([isActive])
@@index([ownerId])
@@index([sourceAssetVariantId])
@ -169,6 +176,47 @@ model ProductSKU {
@@index([warehouseLocationId])
}
// 正式 SKU 的资产资料结构化同步副本
model ProductSkuAssetProfile {
id String @id @default(cuid())
skuId String @unique
sku ProductSKU @relation(fields: [skuId], references: [id], onDelete: Cascade)
primarySourceAssetVariantId String?
sourceAssetVariantIds String[] @default([])
sourceAssetFamilyId String?
sourceAssetSeriesId String?
model String?
modelName String?
specCode String?
specName String?
barcode String?
size Json
color String?
material String?
imageUrl String?
sourceStatus ProductSkuAssetSourceStatus @default(ACTIVE)
skuDeletedAt DateTime?
syncedAt DateTime
lockedFields String[] @default([])
lockedReasons Json
sourceVariants Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([primarySourceAssetVariantId])
@@index([sourceAssetVariantIds], type: Gin)
@@index([sourceAssetFamilyId])
@@index([sourceAssetSeriesId])
@@index([specCode])
@@index([barcode])
@@index([sourceStatus])
@@index([skuDeletedAt])
}
// 产品协作者关系表
model ProductCollaborator {
productId String
@ -205,6 +253,8 @@ model IntakeNonIntegratedProduct {
updatedAt DateTime @updatedAt
deletedAt DateTime?
approvalWorkflows ApprovalWorkflow[]
skuItems IntakeNonIntegratedSkuItem[]
profile IntakeNonIntegratedProductProfile?
@@unique([spu, brand])
@@index([spu])
@ -216,6 +266,222 @@ model IntakeNonIntegratedProduct {
@@index([deletedAt, createdAt])
}
model IntakeNonIntegratedProductProfile {
id String @id @default(cuid())
productId String @unique
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
productName String @default("")
productImageUrl String? @db.Text
productCategory String @default("")
salesCategory String @default("")
productYear String @default("")
productStatus String @default("")
productDevelopmentKind String @default("")
goodsType String @default("")
materialType String @default("")
productionType String @default("")
size String @default("")
color String @default("")
tagMaterial String @default("")
priceBand String @default("")
demandFlowNo String @default("")
processTemplate String @default("")
productPlanning String @default("")
brandPlanning String @default("")
demandDepartment String @default("")
spuOwner String @default("")
operationsOwner String @default("")
purchaseOwner String @default("")
visualDirector String @default("")
visualPlanning String @default("")
structureDesigner String @default("")
sourceSpu String @default("")
sourceSkuId String @default("")
sourceCategory String @default("")
sourceItemType String @default("")
sourceSalesStatus String @default("")
sourceEnabled String @default("")
sourceStockDisabled String @default("")
stockSkuCount Int @default(0)
stockHasOnSaleSku Boolean @default(false)
source String @default("PAYLOAD_SYNC")
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productName])
@@index([productCategory])
@@index([salesCategory])
@@index([productStatus])
@@index([productDevelopmentKind])
@@index([spuOwner])
@@index([purchaseOwner])
@@index([sourceSpu])
@@index([sourceSkuId])
@@index([sourceCategory])
@@index([sourceItemType])
@@index([sourceSalesStatus])
@@index([sourceEnabled])
}
model IntakeNonIntegratedSkuItem {
id String @id
productId String
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
skuItemId String
sortOrder Int
skuSequence Int @default(0)
batchNo Int @default(1)
packageGroupId String @default("")
image String? @db.Text
imageName String @default("")
model String @default("")
skuCode String @default("")
specName String @default("")
barcode String @default("")
size String @default("")
color String @default("")
material String @default("")
boardMaterialType String @default("")
versionNo String @default("V01")
attributeCode String @default("")
drawingId String @default("")
installMethod String @default("")
executeStandard String @default("")
nationalStandardCompliant String @default("")
manufacturer String @default("")
manufacturerCode String @default("")
salesStatus String @default("")
tagMaterial String @default("")
model3dDataUrl String? @db.Text
source String @default("")
sourceTable String @default("")
sourceSkuId String @default("")
contentLocked Boolean @default(false)
dataQualityStatus IntakeNonIntegratedSkuDataQualityStatus @default(INCOMPLETE)
missingRequiredFields String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, skuItemId])
@@index([productId])
@@index([skuCode])
@@index([barcode])
@@index([model])
@@index([dataQualityStatus])
@@index([sourceTable, sourceSkuId])
}
model MasterDataQualityDecision {
id String @id @default(cuid())
targetType String
targetId String
targetBusinessKey String @default("")
status String @default("APPROVED_INCOMPLETE")
missingFields String[] @default([])
reason String @db.Text
decidedBy String
decidedAt DateTime @default(now())
resolvedBy String?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([targetType, targetId])
@@index([targetBusinessKey])
@@index([status])
@@index([decidedAt])
}
model ProductPatentRecord {
id String @id
patentGroupKey String @default("")
productId String @default("")
linkedProductIds String[] @default([])
linkedProductNames String[] @default([])
linkedSpus String[] @default([])
linkedBrands String[] @default([])
productDisplayName String @default("")
productImage String? @db.Text
productImageName String @default("")
patentStatus String @default("待确认")
patentType String @default("外观设计")
patentName String @default("")
spu String @default("")
saRequired String @default("否")
salesStatus String @default("在售")
designer String @default("")
applicationNo String @default("")
patentNo String @default("")
applicationDate String @default("")
authorizationDate String @default("")
firstFeeDate String @default("")
annualFees Json @default("{}")
annualFeeDates Json @default("{}")
nextFeeDueDate String @default("")
feeAmount String @default("")
patentCertificateFileName String @default("")
patentCertificateFileUrl String? @db.Text
patentCertificateFileMimeType String @default("")
patentCertificateStorageKey String @default("")
annualFeeNote String @default("")
continueDecision String @default("待判断")
decisionReason String @default("")
owner String @default("")
remark String @default("")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([patentGroupKey])
@@index([productId])
@@index([spu])
@@index([patentStatus])
@@index([patentNo])
@@index([applicationNo])
@@index([designer])
@@index([nextFeeDueDate])
@@index([sortOrder])
}
model IntakeReferenceLibraryItem {
id String @id @default(cuid())
libraryType String
itemKey String
code String @default("")
name String @default("")
category String @default("")
letter String @default("")
payload Json @default("{}")
sortOrder Int @default(0)
isActive Boolean @default(true)
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([libraryType, itemKey])
@@index([libraryType, isActive, sortOrder])
@@index([libraryType, code])
@@index([libraryType, name])
@@index([category])
}
model SpuNumberCounter {
id String @id @default(cuid())
productCategory String @unique
prefix String @unique
lastUsedIndex Int @default(0)
sourceLibraryItemId String?
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([prefix])
@@index([sourceLibraryItemId])
}
// ============================================
// 装配相关
// ============================================
@ -496,6 +762,7 @@ model Substrate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 4字段规格由服务层收口:相同规格+相同花色拒绝重复,相同规格+不同花色并入已有基材
@@index([isActive])
@@index([createdBy])
@ -504,7 +771,6 @@ model Substrate {
@@index([thicknessSpecId])
@@index([surfaceOptionId])
@@index([systemNameOptionId])
// 4字段规格由服务层收口:相同规格+相同花色拒绝重复,相同规格+不同花色并入已有基材
}
model SubstrateFinish {
@ -874,6 +1140,7 @@ model PreSku {
packagingSnapshot Json?
notes String? @db.Text
basicInfo PreSkuBasicInfo?
packagingPlan PreSkuPackagingPlan?
formalizedSku ProductSKU? @relation("PreSkuFormalizedSku")
locationBindings PreSkuLocationBinding[]
@ -886,6 +1153,38 @@ model PreSku {
@@index([status])
}
model PreSkuBasicInfo {
id String @id @default(cuid())
preSkuId String @unique
preSku PreSku @relation(fields: [preSkuId], references: [id], onDelete: Cascade)
model String?
formalSkuCode String?
barcode String?
size String?
color String?
material String?
specName String?
versionNo String?
setCount String?
attributeCode String?
drawingId String?
installMethod String?
manualUpdatedBy String?
manualUpdatedByName String?
manualUpdatedAt DateTime?
source String @default("DRAFT_GROUP")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([model])
@@index([formalSkuCode])
@@index([barcode])
@@index([specName, versionNo])
}
model PreSkuPackagingPlan {
id String @id @default(cuid())
preSkuId String @unique
@ -1125,6 +1424,17 @@ enum AssetSkuSyncEventStatus {
FAILED
}
enum ProductSkuAssetSourceStatus {
ACTIVE
DELETED
MISSING
}
enum IntakeNonIntegratedSkuDataQualityStatus {
READY
INCOMPLETE
}
enum PackagingSourceType {
PRODUCT
PRE_SKU

View File

@ -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();
});

View File

@ -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()
})

View File

@ -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()
})

View File

@ -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,9 +82,11 @@ async function main() {
})
let scanned = 0
let candidates = 0
let filled = 0
let skipped = 0
try {
for (const sku of skus) {
scanned += 1
const cadData = readSkuCadDataFromAttributes(
@ -39,6 +107,12 @@ async function main() {
continue
}
candidates += 1
if (!apply) {
console.log(`[backfill] dry-run candidate ${sku.skuCode}`)
continue
}
await ensureSkuStructuralData({
productId: sku.productId,
skuId: sku.id,
@ -48,11 +122,16 @@ async function main() {
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()
})

View File

@ -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([

View File

@ -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()
}
}

View File

@ -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

View File

@ -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();
});

View File

@ -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() {
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))
} finally {
await prisma.$disconnect()
}
}
main()
@ -16,6 +41,3 @@ main()
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -1,14 +1,42 @@
/**
* 创建测试产品数据
*/
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('开始创建测试产品...')
try {
// 获取第一个用户作为创建者和 SKU 负责人
const user = await prisma.user.findFirst({
where: { status: 'ACTIVE' },
@ -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,
@ -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: {
@ -230,6 +262,9 @@ async function main() {
}
console.log('\n✅ 所有测试产品创建完成!')
} finally {
await prisma.$disconnect()
}
}
main()
@ -237,6 +272,3 @@ main()
console.error('❌ 创建失败:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -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();
});

View File

@ -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,
}
}

View File

@ -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,7 +176,13 @@ async function main() {
printHelp()
return
}
assertApplyGuards(options)
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,
@ -97,11 +190,12 @@ async function main() {
})
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()
})

View File

@ -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()
})

View File

@ -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()
}
}

View File

@ -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,8 +61,11 @@ const THUMBNAILS = {
}
async function main() {
assertLocalModelUrlUpdateAllowed()
const { prisma } = await import('../src/lib/prisma')
console.log('开始更新产品模型 URL...\n')
try {
// 获取所有产品
const products = await prisma.product.findMany({
orderBy: { modelNumber: 'asc' }
@ -121,8 +139,10 @@ async function main() {
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())

View File

@ -2,14 +2,42 @@
* 更新测试数据脚本
* 修复数据库中的测试产品数据,使其可以正常显示
*/
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('开始更新测试数据...')
try {
// 获取所有产品
const products = await prisma.product.findMany({
include: {
@ -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 {
@ -136,6 +163,9 @@ async function main() {
}
console.log('\n✅ 测试数据更新完成!')
} finally {
await prisma.$disconnect()
}
}
main()
@ -143,6 +173,3 @@ main()
console.error('❌ 更新失败:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -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],

View File

@ -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()
})
})

View File

@ -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),
},

View File

@ -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) {

View File

@ -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({

View File

@ -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,8 +50,7 @@ export async function POST(
)
// 记录操作日志
await prisma.activityLog.create({
data: {
await logActivity({
userId: reqCtx.user.id,
type: 'PRODUCT_UPDATE',
targetType: 'Product',
@ -59,7 +59,6 @@ export async function POST(
metadata: {
restoredSkuCount: result.restoredSkuCount,
},
},
})
ctx.complete(200)
@ -110,8 +109,7 @@ export async function DELETE(
)
// 记录操作日志
await prisma.activityLog.create({
data: {
await logActivity({
userId: reqCtx.user.id,
type: 'PRODUCT_DELETE',
targetType: 'Product',
@ -123,7 +121,6 @@ export async function DELETE(
deletedSkuCount: result.deletedSkuCount,
permanent: true,
},
},
})
ctx.complete(200)

View File

@ -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,8 +39,7 @@ export async function POST(
)
// 记录操作日志
await prisma.activityLog.create({
data: {
await logActivity({
userId: reqCtx.user.id,
type: 'SKU_UPDATE',
targetType: 'ProductSKU',
@ -49,7 +49,6 @@ export async function POST(
skuCode: result.skuCode,
productId: result.productId,
},
},
})
ctx.complete(200)
@ -97,8 +96,7 @@ export async function DELETE(
)
// 记录操作日志
await prisma.activityLog.create({
data: {
await logActivity({
userId: reqCtx.user.id,
type: 'SKU_DELETE',
targetType: 'ProductSKU',
@ -109,7 +107,6 @@ export async function DELETE(
productId: sku.productId,
permanent: true,
},
},
})
ctx.complete(200)

View File

@ -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.
}
})()
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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),
let isCancelled = false
setIsServerLoading(true)
setServerError('')
setServerNotice('')
listIntakeReferenceLibraryItems(ACCESSORY_REFERENCE_LIBRARY_TYPE)
.then((rows) => {
if (isCancelled) return
const serverItems = decodeAccessoryReferenceLibraryRows(rows).map(
normalizeScrewItem,
)
window.dispatchEvent(new Event(ACCESSORY_LIBRARY_CHANGED_EVENT))
} catch {
console.warn('配件资料库本地缓存空间不足,未能完成持久化保存。')
setItems(serverItems)
setServerNotice(
serverItems.length ? '' : '服务端暂无配件资料,新增后写入服务端资料库',
)
})
.catch((error) => {
if (isCancelled) return
setServerError(error instanceof Error ? error.message : '配件资料库读取失败')
})
.finally(() => {
if (!isCancelled) {
setIsServerLoading(false)
}
})
return () => {
isCancelled = true
}
}, [])
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)
}
}
}, [items])
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>
}

View File

@ -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)
})
})

View File

@ -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,19 +75,8 @@ 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 => {
function normalizeAlphabetLibraryItems(items: AlphabetReferenceLibraryItem[]) {
return items.filter((item): item is AlphabetLibraryItem => {
return (
item &&
typeof item.id === 'string' &&
@ -97,9 +92,6 @@ function readLibraryItems(storageKey: string) {
typeof item.updatedAt === 'string'
)
})
} catch {
return []
}
}
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))
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)
}
} catch {
console.warn(`${entityName}资料库本地缓存空间不足,未能完成持久化保存。`)
})
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))
}
return [...current, nextItem]
})
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))
}
} 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>
}

View File

@ -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)
}
}, [categories])
})
return () => {
isCancelled = true
}
}, [])
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>

View File

@ -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: [],

View File

@ -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,7 +2512,9 @@ function getDraftGroupSeriesNameOptions(props: IntakeClientProps) {
function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[] {
if (isEditorMode(props)) {
return props.detail.preSkus.map((preSku, index) => ({
return props.detail.preSkus.map((preSku, index) => {
const skuFields = readPreSkuSkuFields(preSku);
return {
id: preSku.id,
draftGroupId: props.detail.draftGroup.id,
spuCode:
@ -2534,12 +2522,7 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[]
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,
code: skuFields.skuCode || preSku.customName || preSku.defaultName,
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, "seriesName") ||
@ -2549,11 +2532,12 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[]
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields: readPreSkuSkuFields(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,6 +7607,18 @@ function ProductPatentStatusDashboard({
};
function refreshRecords() {
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,
@ -7622,6 +7626,7 @@ function ProductPatentStatusDashboard({
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[]) {
void saveProductPatentRecordsToDatabase(nextRecords).catch(() => {
saveProductPatentRecords(nextRecords);
setSaveMessage("本地已缓存,数据库保存失败");
window.setTimeout(() => setSaveMessage(""), 2400);
});
setSaveMessage("已保存");
window.setTimeout(() => setSaveMessage(""), 1600);
}

View File

@ -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"
/>
)
}

View File

@ -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,

View File

@ -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
function normalizeWorkspaceRules(rules: unknown[]) {
const normalizedRules = rules
.map((rule, index) => normalizeRuleRecord(rule, index))
.filter((rule): rule is SpuRule => Boolean(rule));
return rules.length > 0 ? rules : getSeedRules();
} catch {
return getSeedRules();
}
}
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(
const nextRules = dedupeRules(
editingId
? current.map((rule) => (rule.id === editingId ? nextRule : rule))
: [...current, nextRule],
),
? 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" />

View File

@ -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
}
}, [])
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)
}
}
}, [referenceSteelPrice, savedItems])
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>

View File

@ -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,14 +210,18 @@ describe('adaptDraftGroupToIntegratedDefaults', () => {
spuCode: 'GBGJ-DNZ2001',
skuCode: 'GBGJ-DNZ200102',
},
basicInfo: {
...buildDraftGroupDetail().preSkus[1]!.basicInfo!,
formalSkuCode: 'GBGJ-DNZ200102',
},
},
],
})
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',
@ -187,7 +229,7 @@ describe('adaptDraftGroupToIntegratedDefaults', () => {
])
})
it('can detect brand from summary text when spuCode has no prefix', () => {
it('detects brand from structured SKU code instead of legacy summary text', () => {
const detail = buildDraftGroupDetail({
preSkus: [
{
@ -195,7 +237,11 @@ describe('adaptDraftGroupToIntegratedDefaults', () => {
displaySummary: {
spuCode: 'DNZ3001',
skuCode: 'DNZ300101',
summaryText: '品牌:朴美;适配渠道:朴美',
summaryText: '品牌:代理品牌-顾家;适配渠道:代理品牌-顾家',
},
basicInfo: {
...buildDraftGroupDetail().preSkus[0]!.basicInfo!,
formalSkuCode: 'GBPM-DNZ300101',
},
},
],
@ -203,8 +249,8 @@ 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('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('')
})
})

View File

@ -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,17 +76,21 @@ 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 {
@ -109,7 +104,7 @@ export function adaptDraftGroupToIntegratedDefaults(
return {
seriesName: readSeriesName(detail),
baseSpu: pickBaseSpu(detail),
baseSpu: pickBaseSpu(detail, skuCodes),
defaultBrand: pickDefaultBrand(adaptableBrands),
adaptableBrands,
skuItems,

View File

@ -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,

View File

@ -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,9 +559,7 @@ 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([
writeSpuRuleLibraryRules([
{
id: "test-desk-rule",
productCategory: "书桌",
@ -541,8 +567,7 @@ describe("NonIntegratedDetailClient", () => {
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,11 +768,27 @@ 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([
(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: "测试",
@ -758,8 +796,7 @@ describe("NonIntegratedDetailClient", () => {
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,9 +925,7 @@ 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([
writeSpuRuleLibraryRules([
{
id: "remote-upload-rule",
productCategory: "书桌",
@ -894,8 +933,7 @@ describe("NonIntegratedDetailClient", () => {
spuIndex: 240,
enabled: true,
},
]),
);
]);
const { container } = render(<NonIntegratedDetailClient mode="create" />);
await actor.type(screen.getByLabelText("名称"), "远程开品");
@ -1307,9 +1345,7 @@ 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([
writeSpuRuleLibraryRules([
{
id: "blank-opening-code-rule",
productCategory: "书桌",
@ -1317,8 +1353,7 @@ describe("NonIntegratedDetailClient", () => {
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");
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

View File

@ -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,6 +29201,14 @@ function renderFactoryQualityWorkspace() {
onChange={() => undefined}
/>
) : (
<>
<BasicField
label="SPU"
value={seriesCreateEstimatedSpuLabel}
readOnly
compact
onChange={() => undefined}
/>
<BasicField
label="年份"
value={
@ -29130,6 +29220,7 @@ function renderFactoryQualityWorkspace() {
compact
onChange={() => undefined}
/>
</>
)}
<AdaptableBrandField
value={formValues.adaptableBrands}

View File

@ -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,
}
: {}),
}),
})

View File

@ -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
return nonIntegratedRuntimeProducts
.map((item) => toProductRecord(item))
.filter((item): item is NonIntegratedProductRecord => item !== null)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
} catch {
return [];
}
}
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);

View File

@ -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,7 +253,9 @@ export default async function IntakePage({
)
const eligibleSkuList = eligibleDraftGroupDetails.flatMap((detail) =>
detail.preSkus.map((preSku, index) => ({
detail.preSkus.map((preSku, index) => {
const skuFields = readPreSkuSkuFields(preSku)
return {
id: preSku.id,
draftGroupId: detail.draftGroup.id,
spuCode:
@ -281,26 +264,25 @@ export default async function IntakePage({
detail.draftGroup.code ||
`SPU-${String(index + 1).padStart(3, '0')}`,
code:
readPreSkuTextField(preSku, 'draftSkuCode') ||
readPreSkuTextField(preSku, 'skuSequenceCode') ||
readPreSkuTextField(preSku, 'skuCode') ||
skuFields.skuCode ||
preSku.customName ||
preSku.defaultName,
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, 'seriesName') ||
readDraftGroupSeriesNameForIntake(detail.draftGroup) ||
detail.draftGroup.assetSeriesName ||
detail.draftGroup.name,
draftGroupName: detail.draftGroup.name,
status: mapWorkbenchStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields: readPreSkuSkuFields(preSku),
skuFields,
model3dPreviewNodes: preSku.previewNodes.length
? preSku.previewNodes
: preSku.assemblyWorkspace?.nodes ?? [],
})),
}
}),
)
const sourceWorkspace =

View File

@ -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) || "[]",
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 (!Array.isArray(parsed)) return [];
return parsed
}
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));
} catch {
return [];
}
}
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[],

View File

@ -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;

View File

@ -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() {
);
}
export function readSpuRuleLibraryRules() {
if (typeof window === "undefined") return getSeedRules();
function readPayloadRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
try {
const storedValue = window.localStorage.getItem(SPU_RULE_LIBRARY_STORAGE_KEY);
if (!storedValue) return getSeedRules();
function readPayloadText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
const parsedValue = JSON.parse(storedValue);
if (!Array.isArray(parsedValue)) return getSeedRules();
const rules = parsedValue
.map((rule) => normalizeRule(rule))
export function normalizeSpuRuleLibraryRules(rules: unknown[]) {
const normalizedRules = rules
.map((rule) => normalizeRule(rule as SpuRuleLibraryRuleLike))
.filter((rule): rule is SpuRuleLibraryRuleLike => Boolean(rule));
return rules.length > 0 ? rules : getSeedRules();
} catch {
return normalizedRules.length > 0 ? normalizedRules : getSeedRules();
}
export function readSpuRuleLibraryRules() {
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;
}

View File

@ -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)',

View File

@ -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 => ({
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,
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
})(),
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>
)

View File

@ -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[]

View File

@ -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 {

View File

@ -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)' }}
>

View File

@ -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)' }}
>

View File

@ -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 => ({
allSkus={product.skus.map(s => {
const legacyAttributes = toLegacySkuDisplayAttributes(s.attributes)
return {
id: s.id,
skuCode: s.skuCode,
price: Number(s.price),
attributes: filterDisplaySkuAttributes(s.attributes as Record<string, unknown> | null),
}))}
legacyAttributes,
displayTags: buildSkuDisplayTags({
assetProfile: s.assetProfile,
legacyAttributes,
}),
}
})}
steps={steps}
panels={displayPanels}
materialAssignmentsByNode={materialAssignmentsByNode}

View File

@ -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 {

View File

@ -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}

View File

@ -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}`,

View File

@ -40,7 +40,7 @@ interface TrashSKU {
expiresAt: string
daysRemaining: number
price: number
attributes: Record<string, unknown> | null
legacyAttributes: Record<string, string> | null
}
interface TrashAssetFamily {

View File

@ -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) => ({
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: readPreSkuTextField(preSku, 'skuCode') || preSku.customName || preSku.defaultName,
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

View File

@ -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)' }}>

View File

@ -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

View File

@ -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',

View File

@ -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',
})
})
})

View File

@ -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) => {

View File

@ -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')
})
})

View File

@ -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) => {

View File

@ -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)

View File

@ -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([
{

View File

@ -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, '')
}

View File

@ -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()

View File

@ -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',
})
})
})

View File

@ -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) => {

View File

@ -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)

View File

@ -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)
}

View File

@ -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

View File

@ -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')
})
})

View File

@ -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>
}
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
}
const persistedValue = readPersistedSteelFrameLibraryValue()
const referenceSteelPrice =
typeof persistedValue.referenceSteelPrice === 'string' && persistedValue.referenceSteelPrice.trim()
? persistedValue.referenceSteelPrice.trim()
: STEEL_FRAME_TEMPLATE_REFERENCE_PRICE
const items = resolvePersistedSteelFrameItems(persistedValue)
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()

View File

@ -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,

View File

@ -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,

View File

@ -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',
}),
],
})
})
})

View File

@ -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()
})
})

View File

@ -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> {

View File

@ -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,
},
})

View File

@ -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,

View File

@ -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) {

View File

@ -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 },
})
}

View File

@ -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,
},
})

View File

@ -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: {

View File

@ -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