# 原产品多对一合并(Multi Origin Good Merge)设计与实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > > 前置:按 enterprise-git-spec 建分支 `feature/multi-origin-good-merge`。 **Goal:** 左栏一个 Good 关联多个原产品(1 主源 + N 副源),变体合并返回,右栏统计正确,小程序副源入口可用,存量数据零迁移。 **Architecture:** 新增 `good_origin_goods` 中间表存副源(主源不入表,走现有 `goods.origin_good_id` 外键);admin/public/origin-goods 三个 API 模块在读写处合并"主源 ∪ 副源"两个来源;前端配置弹窗按名称截断规则自动勾选同分类兄弟原产品。 **Tech Stack:** NestJS、Prisma(PostgreSQL)、Jest(API 集成测试)、Vue3 + Element Plus、Vitest(admin 纯函数测试)、pnpm + turbo。 ## 1. 背景与问题 上游 SDS 同步的 `origin_goods` 按 `sdsGoodId` 唯一 upsert,存在**多条名称几乎相同、仅尾部(工厂/仓库)不同**的原产品(实测 552 条中,`国家(物流备注)品名-SKU-工艺位置[-仓库名]` 格式里 154 条带第 4 段仓库名,详见 [data-cleaning/README.md](../../data-cleaning/README.md))。 当前左右配置功能:右栏(原产品库)点「配置」→ `POST /goods` 创建左栏商品,`goods.origin_good_id` 是**单值非空外键**,一个 Good 只能挂一个原产品。由此产生两个问题: 1. 同名原产品(不同工厂/颜色/价格)只能各配各的 → 左栏出现多条同名商品,小程序端(`public` 模块)不能给用户看到重复商品。 2. 只配一条时,右栏另外几条同名原产品仍显示「未配置」,按钮还挂着。 ## 2. 目标 - **左栏一个 Good 可关联多个原产品**:1 主源(现有外键)+ N 副源(新中间表)。 - **变体合并**:查 Good 时主源 + 副源的 `origin_good_variants` 一次全部带回,全部拼接不去重,靠来源字段区分。 - **右栏统计正确**:被主源或副源引用过的原产品都显示已配置。 - **小程序详情入口不失效**:副源的 `sdsGoodId` 也能查到合并后的 Good。 - **存量数据无损**:现有 1:1 关系零迁移,重复的存量 Good 通过后台手动合并清理。 ## 3. 方案对比与选择 | 方案 | 结论 | 原因 | |---|---|---| | A. 中间表 `good_origin_goods` | ✅ 采用 | 真外键 + Cascade 引用完整;Prisma 原生 relation 可 include;统计查询干净(项目已有 `good_tags` 同构先例) | | B. `goods` 表加 `bigint[]` 数组列 | ❌ | PostgreSQL 数组列不能建外键,会产生悬挂 id;Prisma 只能当裸标量,无关联语义 | | C. 同步/清洗层直接合并 OriginGood | ❌ | 丢失各工厂独立变体;每次同步要维护合并规则 | | D. OriginGood 加指向 Good 的外键 | ❌ | 破坏现有「一个原产品配到多个国家/分类」的 1:N 能力 | **关键设计:主源不写入中间表。** 主源永远走 `goods.origin_good_id`(原样保留),中间表只存副源。避免同一关系存两处、换主源时要双向同步。 ## 4. 数据库设计 新增表(`goods` 表零改动): ```prisma model GoodOriginGood { goodId BigInt @map("good_id") originGoodId BigInt @map("origin_good_id") createdAt DateTime @default(now()) @map("created_at") good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@id([goodId, originGoodId]) @@index([originGoodId]) @@map("good_origin_goods") } ``` - 复合主键 `(good_id, origin_good_id)`,无自增 id。 - 双向 `Cascade`:删 Good 或删 OriginGood 自动清理关联行。 - `Good` / `OriginGood` 模型各加反向 `mergedOriginGoods GoodOriginGood[]` / `mergedIntoGoods GoodOriginGood[]`。 - 迁移:仅 `CREATE TABLE` + 外键;存量 1:1 数据不需要回填(主源不在表内)。 关系示意: ``` goods #100 ── origin_good_id(主源,现有外键)──────────→ origin_goods #1 │ └── good_origin_goods(副源,新表) ├── (100, 2) → origin_goods #2 └── (100, 3) → origin_goods #3 ``` ## 5. 后端改动(apps/api) ### 5.1 goods 模块(admin 接口) - `CreateGoodDto` / `UpdateGoodDto` / `BatchCreateGoodDto`:新增可选 `mergedOriginGoodIds?: number[]`(副源,不包含主源)。 - `goods.service.ts`: - `create()` / `batchCreate()`:事务内校验副源存在且不等于主源、去重后 `createMany` 写入。 - `update()`:`mergedOriginGoodIds` 全量覆盖(deleteMany + createMany);换主源 `originGoodId` 时若旧主源不在新的副源列表中,由前端保证传入(后端不做隐式迁移)。 - 详情同步触发:主源 + 副源中所有 `source=SDS && !hasDetail` 的都 `queueProductDetailSync`。 - `GOOD_INCLUDE` 增加 `mergedOriginGoods: { include: { originGood: { include: { detail: true, variants, _count } } } }`。 - `GoodDto` 增加 `mergedOriginGoods: Array<{ id, sdsGoodId, goodName, goodPrice, hasDetail, variantCount, ... }>` 摘要数组。 - `GoodDetailDto.variants`:主源 + 副源变体拼接,每条增加来源标注 `originGoodId` / `originGoodName`;`variantCount` 为合并总数。 ### 5.2 origin-goods 模块(右栏树) `getTree()` 的三个统计口径(`configuredCount` / `configuredCountries` / `configuredTags`)由「仅 goods 表 groupBy」改为「goods 表(主源)∪ good_origin_goods(副源)」两个查询合并。效果:合并组内全部同名原产品显示已配置。 ### 5.3 public 模块(小程序接口) - `getGood(sdsGoodId)`:`where` 改为 `OR: [{ originGood: { sdsGoodId } }, { mergedOriginGoods: { some: { originGood: { sdsGoodId } } } }]`(副源 id 也能命中同一 Good)。 - `toPublicGoodDetail()`:`variants` 与 `mediaByColor` 合并主源 + 副源全部变体;`groupImagesByColor` 按颜色归组,同色图片自然合并。 - `price` / `detail` / 排序 / 价格筛选 / 上下架过滤:**仍以主源为准**(已确认决策)。 - 列表接口(`getGoods` / `getHomeGoods` 等):不返回变体明细,除 `where.originGood.delisted` 语义不变外无改动。 ## 6. 前端改动(apps/admin,GoodsView.vue) ### 6.1 配置弹窗增强(新配置流程) - 打开弹窗时列出**右栏树同分类(同父节点)下的兄弟原产品**。 - 自动勾选规则:名称截断到「工艺位置」段后相同的兄弟项。截断规则(来自 [data-cleaning/README.md](../../data-cleaning/README.md) 命名调查): - 格式:`国家(物流备注)品名-SKU-工艺位置[-仓库名]`,`)`之后按 `-` 分段,**保留前 3 段**(品名-SKU-工艺),丢弃可选的第 4 段仓库名。 - 匹配 key = 保留的 3 段(或不足 3 段时取全部);截断逻辑抽成独立纯函数 + 单测,规则常量集中定义便于调整。 - 11 条异常命名(缺国家前缀、半角括号等)匹配不上就不自动勾选,可手动勾。 - 主源默认 = 点击「配置」的那个,列表中标记「主」;可手动改选主源。 - 提交 `POST /goods` 带 `mergedOriginGoodIds`(不含主源)。 - 匹配项默认展开高亮;未匹配兄弟项收入搜索框之后手动勾选。 ### 6.2 Good 编辑弹窗:关联原产品管理(存量合并入口) - 新增「关联原产品」区:展示主源(标记)+ 副源列表。 - 操作:添加副源(同分类推荐 + 名称搜索)、移除副源、切换主源(旧主源自动进副源列表)。 - 存量清理流程:把多余 Good 的原产品添加为保留 Good 的副源 → 删除多余 Good。 ### 6.3 左栏节点角标 合并商品(副源数 > 0)显示 `×N`(N = 主源 + 副源总数)轻量角标。 ## 7. 已确认决策记录 | 决策点 | 结论 | |---|---| | 存储方案 | 中间表(主源不入表) | | 变体重叠(两工厂同色) | 全部拼接不去重,带来源标注 | | 主源下架但副源在售 | 商品隐藏(主源为准),换主源可恢复 | | 存量重复 Good | 后台手动合并(编辑弹窗 + 删除),不写自动清洗脚本 | | 详情字段(详情文本/价格/主图) | 以主源为准 | | 列表接口 | 不带变体明细,维持现状 | ## 8. 测试策略 - API 单测(Jest,遵循 javascript-testing-patterns): - create/update:副源写入、含主源 id 报错、去重、不存在 id 报错。 - getTree:副源引用计入 configuredCount / configuredCountries。 - public getGood:副源 sdsGoodId 命中、variants 拼接、mediaByColor 同色合并、主源下架仍隐藏。 - Cascade:删 Good / 删 OriginGood 清理关联行。 - 前端单测:名称截断匹配纯函数(正常 3/4 段、异常命名、空值)。 - 回归:现有 goods / origin-goods / public 测试全部通过。 ## 9. 兼容性说明 - `POST /goods` 的 `mergedOriginGoodIds` 可选,现有调用方(含 [data-cleaning/sync-apply.mjs](../../data-cleaning/) 批量脚本)零改动可继续运行;后续脚本可增量传副源。 - public DTO 只增字段不改既有字段,小程序端向后兼容。 - 部署顺序:先跑 migration(新表,无破坏性),再发 API,最后发 admin 前端。 ## 10. 风险与边界 - 名称截断规则基于 2026-08-27 生产快照调查(541/552 匹配主格式),规则抽成常量 + 纯函数,便于按新数据调整。 - 合并组变体数量可能较大(N 个工厂 × 各自 SKU),详情响应体量增大;列表不受影响。 - 主源唯一决定可见性/价格/详情 → 运营需知晓「换主源」的影响面(编辑弹窗中提示文案说明)。 --- # 实施计划 > 测试运行方式:API 集成测试直连本地库(`apps/api/.env` 的 `DATABASE_URL`,与现有 `goods.service.spec.ts` 相同模式)。前端构建验证 `pnpm --filter @inkreach/admin build`(含 vue-tsc 类型检查)。 ### Task 1: 数据库 — GoodOriginGood 表 **Files:** - Modify: `apps/api/prisma/schema.prisma` - Create: `apps/api/prisma/migrations/_add_good_origin_goods/migration.sql`(用 `prisma migrate dev --name add_good_origin_goods` 自动生成) - [ ] **Step 1: 修改 schema.prisma** `OriginGood` 模型(约 L36-38)加反向关系: ```prisma goods Good[] detail OriginGoodDetail? variants OriginGoodVariant[] mergedIntoGoods GoodOriginGood[] ``` `Good` 模型(约 L217)加反向关系: ```prisma goodTags GoodTag[] mergedOriginGoods GoodOriginGood[] ``` 在 `GoodTag` 模型之后新增: ```prisma // ---------- Good ↔ OriginGood (merged secondary sources, M:N) ---------- // Primary source stays on goods.origin_good_id and is NOT stored here. model GoodOriginGood { goodId BigInt @map("good_id") originGoodId BigInt @map("origin_good_id") createdAt DateTime @default(now()) @map("created_at") good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@id([goodId, originGoodId]) @@index([originGoodId]) @@map("good_origin_goods") } ``` - [ ] **Step 2: 生成 migration 并更新 client** ```bash cd /opt/inkreach pnpm --filter @inkreach/api exec prisma migrate dev --name add_good_origin_goods pnpm --filter @inkreach/api exec prisma generate ``` 预期:生成新 migration 目录,`prisma migrate dev` 输出 `Your database is now in sync with your schema`。生成的 SQL 应等价于: ```sql CREATE TABLE "good_origin_goods" ( "good_id" BIGINT NOT NULL, "origin_good_id" BIGINT NOT NULL, "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY ("good_id", "origin_good_id") ); CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id"); ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION; ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION; ``` - [ ] **Step 3: 验证** ```bash pnpm --filter @inkreach/api exec prisma validate pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:validate 通过;现有测试全绿(新表为空,不影响存量行为)。 - [ ] **Step 4: Commit** ```bash git add apps/api/prisma git commit -m "feat(db): add good_origin_goods junction table for merged origin goods" ``` ### Task 2: goods service — create 支持副源 + DTO 摘要 **Files:** - Modify: `apps/api/src/goods/dto/create-good.dto.ts` - Modify: `apps/api/src/goods/dto/update-good.dto.ts` - Modify: `apps/api/src/goods/dto/batch-create-good.dto.ts` - Modify: `apps/api/src/goods/dto/good.dto.ts` - Modify: `apps/api/src/goods/goods.service.ts` - Test: `apps/api/src/goods/goods.service.spec.ts` - [ ] **Step 1: 写失败测试(追加到 goods.service.spec.ts 的 `afterAll` 前的用例区)** ```ts describe('merged origin goods', () => { it('creates a good with merged origin goods and reads them back', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} merged`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])], countryId: Number(countryId), categoryId: Number(categoryId), }); expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual( [originGoodIds[2].toString(), originGoodIds[3].toString()].sort(), ); const fetched = await service.findOne(BigInt(created.id)); expect(fetched.mergedOriginGoods.length).toBe(2); }); it('rejects mergedOriginGoodIds containing the primary', async () => { await expect( service.create({ goodName: `Goods Test ${stamp} bad-primary`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[1])], countryId: Number(countryId), categoryId: Number(categoryId), }), ).rejects.toThrow(BadRequestException); }); it('rejects mergedOriginGoodIds that do not exist', async () => { await expect( service.create({ goodName: `Goods Test ${stamp} bad-missing`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [999999999], countryId: Number(countryId), categoryId: Number(categoryId), }), ).rejects.toThrow(BadRequestException); }); }); ``` - [ ] **Step 2: 运行确认失败** ```bash pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:FAIL(TS 编译报 `mergedOriginGoodIds` 不存在于 DTO)。 - [ ] **Step 3: DTO 加字段** `create-good.dto.ts`、`update-good.dto.ts` 各加(放在 `originGoodId` 字段之后): ```ts @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' }) @IsOptional() @IsArray() @IsInt({ each: true }) @Min(1, { each: true }) mergedOriginGoodIds?: number[]; ``` `batch-create-good.dto.ts` 的 `BatchCreateItemDto` 同样加该字段。 - [ ] **Step 4: service 实现** `goods.service.ts`: `GOOD_INCLUDE` 加副源(`goodTags` 行之后): ```ts goodTags: { include: { tag: true } }, mergedOriginGoods: { include: { originGood: { include: { detail: true, variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, _count: { select: { variants: true } }, }, }, }, }, ``` 新增两个私有方法(`ensureOriginGood` 附近): ```ts /** Dedupe merged ids and reject any that equals the primary source. */ private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] { if (!ids || ids.length === 0) return []; const unique = [...new Set(ids.map((id) => BigInt(id)))]; if (unique.includes(primaryId)) { throw new BadRequestException('mergedOriginGoodIds 不能包含主源 originGoodId'); } return unique; } private async ensureMergedOriginGoods(ids: bigint[]) { if (ids.length === 0) return; const rows = await this.prisma.originGood.findMany({ where: { id: { in: ids } }, select: { id: true }, }); if (rows.length !== ids.length) { const found = new Set(rows.map((r) => r.id.toString())); const missing = ids.find((id) => !found.has(id.toString())); throw new BadRequestException(`Origin good ${missing} not found`); } } ``` `create()` 中 `ensureReferences` 之后、事务之前: ```ts const mergedIds = this.dedupeMergedIds(BigInt(dto.originGoodId), dto.mergedOriginGoodIds); await this.ensureMergedOriginGoods(mergedIds); ``` 事务内 `goodTag.createMany` 之后: ```ts if (mergedIds.length > 0) { await tx.goodOriginGood.createMany({ data: mergedIds.map((originGoodId) => ({ goodId: created.id, originGoodId, })), }); } ``` `batchCreate()`:事务前对每个 item 预校验(`this.dedupeMergedIds(og.id 需先查出)` 改为直接查库取主源记录后校验),事务内 `tx.good.create` 之后同 `create()` 写入副源: ```ts // 事务内、tx.good.create 之后: const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds); if (itemMerged.length > 0) { const existRows = await tx.originGood.findMany({ where: { id: { in: itemMerged } }, select: { id: true }, }); if (existRows.length !== itemMerged.length) { throw new BadRequestException( `Origin good ${item.mergedOriginGoodIds?.find((x) => !existRows.some((r) => r.id === BigInt(x)))} not found`, ); } await tx.goodOriginGood.createMany({ data: itemMerged.map((originGoodId) => ({ goodId: row.id, originGoodId })), }); } ``` `GoodDto`(`good.dto.ts`):`GoodRelations` 接口加: ```ts mergedOriginGoods?: Array<{ originGood: { id: bigint; sdsGoodId: string; goodName: string | null; goodImage: string | null; goodPrice: unknown; detail?: { sizeChart: unknown; packageSpecs: unknown; productCode: string | null; syncedAt: Date } | null; variants?: Array<{ sdsVariantId: string }>; _count?: { variants: number }; }; }>; ``` `GoodDto` 类加字段与映射(`originGood` 之后): ```ts @ApiProperty({ required: false, type: Array }) mergedOriginGoods!: Array<{ id: string; sdsGoodId: string; goodName: string | null; goodImage: string | null; goodPrice: string | null; hasDetail: boolean; variantCount: number; }>; ``` ```ts mergedOriginGoods: rel.mergedOriginGoods ? rel.mergedOriginGoods.map((m) => ({ id: m.originGood.id.toString(), sdsGoodId: m.originGood.sdsGoodId, goodName: m.originGood.goodName, goodImage: m.originGood.goodImage, goodPrice: m.originGood.goodPrice === null || m.originGood.goodPrice === undefined ? null : (m.originGood.goodPrice as { toString(): string }).toString(), hasDetail: Boolean(m.originGood.detail), variantCount: m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0, })) : [], ``` 所有 `GoodDto.from(x, {...})` 调用点(`create`/`findAll`/`update`/`batchCreate` 等)补传 `mergedOriginGoods: x.mergedOriginGoods`。 - [ ] **Step 5: 运行测试通过** ```bash pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:PASS。 - [ ] **Step 6: Commit** ```bash git add apps/api/src/goods git commit -m "feat(goods): create goods with merged secondary origin goods" ``` ### Task 3: goods service — update 全量覆盖副源 **Files:** - Modify: `apps/api/src/goods/goods.service.ts` - Test: `apps/api/src/goods/goods.service.spec.ts` - [ ] **Step 1: 写失败测试(追加到 `merged origin goods` describe 内)** ```ts it('replaces merged origin goods on update', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} replace`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); const updated = await service.update(BigInt(created.id), { mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])], }); expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( [originGoodIds[3].toString(), originGoodIds[4].toString()].sort(), ); }); it('moves old primary into merged list when switching primary', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} switch`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); const updated = await service.update(BigInt(created.id), { originGoodId: Number(originGoodIds[2]), mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])], }); expect(updated.originGoodId).toBe(originGoodIds[2].toString()); expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( [originGoodIds[1].toString(), originGoodIds[3].toString()].sort(), ); }); it('cascades merged rows on good removal', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} cascade`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); await service.remove(BigInt(created.id)); const rows = await prisma.goodOriginGood.count({ where: { goodId: BigInt(created.id) }, }); expect(rows).toBe(0); }); ``` - [ ] **Step 2: 运行确认失败** ```bash pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:FAIL(update 忽略 mergedOriginGoodIds)。 - [ ] **Step 3: 实现 update()** `update()` 中,在 `await this.findOne(id);` 之后加: ```ts let mergedIds: bigint[] | undefined; if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) { const current = await this.prisma.good.findUniqueOrThrow({ where: { id }, select: { originGoodId: true }, }); const primaryId = dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId; mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds); await this.ensureMergedOriginGoods(mergedIds); } ``` 事务内 `goodTag` 覆盖块之后加: ```ts if (mergedIds !== undefined) { await tx.goodOriginGood.deleteMany({ where: { goodId: id } }); if (mergedIds.length > 0) { await tx.goodOriginGood.createMany({ data: mergedIds.map((originGoodId) => ({ goodId: id, originGoodId })), }); } } ``` 事务结果返回后,把详情同步触发扩展为副源也触发: ```ts const syncTargets = [ result.originGood, ...result.mergedOriginGoods.map((m) => m.originGood), ].filter((og) => og?.source === 'SDS' && og.sdsGoodId && !og.hasDetail); ``` (`create()`/`batchCreate()` 的同步触发同样改用该模式。) - [ ] **Step 4: 运行测试通过** ```bash pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:PASS。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/goods git commit -m "feat(goods): replace merged origin goods on update" ``` ### Task 4: goods 详情 — 变体拼接与来源标注 **Files:** - Modify: `apps/api/src/goods/dto/good.dto.ts` - Test: `apps/api/src/goods/goods.service.spec.ts` - [ ] **Step 1: 写失败测试** ```ts it('returns merged variants with source annotation in detail', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} variants`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); const detail = await service.findOne(BigInt(created.id)); const sources = new Set(detail.variants.map((v) => (v as Record).originGoodId)); expect(sources.has(originGoodIds[1].toString())).toBe(true); expect(sources.has(originGoodIds[2].toString())).toBe(true); expect(detail.originGood.variantCount).toBeGreaterThanOrEqual( detail.variants.filter((v) => (v as Record).originGoodId === originGoodIds[1].toString()).length, ); }); ``` - [ ] **Step 2: 运行确认失败** ```bash pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:FAIL(variants 无 originGoodId 字段)。 - [ ] **Step 3: 实现 GoodDetailDto.fromGood 合并变体** ```ts static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { const base = GoodDto.from(good, rel); const detail = rel.originGood?.detail; const toVariant = (originGoodId: string, originGoodName: string | null) => (variant: NonNullable['variants'] extends (infer V)[] | undefined ? V : never) => ({ ...variant, price: variant.price === null || variant.price === undefined ? null : (variant.price as { toString(): string }).toString(), originGoodId, originGoodName, }); const mergedVariants = [ ...(rel.originGood?.variants ?? []).map( toVariant(good.originGoodId.toString(), rel.originGood?.goodName ?? null), ), ...(rel.mergedOriginGoods ?? []).flatMap((m) => (m.originGood.variants ?? []).map( toVariant(m.originGood.id.toString(), m.originGood.goodName), ), ), ]; return { ...base, originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, variants: mergedVariants, }; } ``` (若上述条件类型写法编译不过,退化为内联 map 两次,保持行为一致即可。) - [ ] **Step 4: 运行测试通过** ```bash pnpm --filter @inkreach/api test -- goods.service.spec.ts ``` 预期:PASS。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/goods git commit -m "feat(goods): merge primary and secondary variants in detail DTO" ``` ### Task 5: origin-goods getTree — 统计合并副源 **Files:** - Modify: `apps/api/src/origin-goods/origin-goods.service.ts` - Test: `apps/api/src/origin-goods/origin-goods.service.spec.ts` - [ ] **Step 1: 写失败测试(追加到该文件,直接用 prisma 建 fixture,与现有用例同模式)** 文件顶部 describe 外补 fixture 变量与树查找 helper: ```ts let treeCountryId: bigint; let treeCategoryId: bigint; let treeOriginA: bigint; let treeOriginB: bigint; function findOgNode(tree: { tree: any[] }, ogId: string): any { let found: any = null; function walk(nodes: any[]) { for (const n of nodes) { if (n.originGoods) { const hit = n.originGoods.find((o: any) => o.id === ogId); if (hit) { found = hit; return; } } if (n.children?.length) walk(n.children); } } walk(tree.tree); if (!found) throw new Error(`og node ${ogId} not found in tree`); return found; } ``` 新用例: ```ts it('counts secondary references as configured', async () => { const sdsCat = `tree-cat-${stamp}`; const country = await prisma.country.create({ data: { countryName: `Tree Country ${stamp}` }, }); treeCountryId = country.id; const cat = await prisma.category.create({ data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat }, }); treeCategoryId = cat.id; const originA = await prisma.originGood.create({ data: { sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat }, }); const originB = await prisma.originGood.create({ data: { sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat }, }); treeOriginA = originA.id; treeOriginB = originB.id; const good = await prisma.good.create({ data: { goodName: `Tree Good ${stamp}`, originGoodId: originA.id, countryId: treeCountryId, categoryId: treeCategoryId, }, }); await prisma.goodOriginGood.create({ data: { goodId: good.id, originGoodId: originB.id }, }); const tree = await service.getTree(); const nodeB = findOgNode(tree, originB.id.toString()); expect(nodeB.configuredCount).toBe(1); expect(nodeB.configuredCountries.length).toBe(1); await prisma.good.delete({ where: { id: good.id } }); }); ``` `afterAll` 追加清理: ```ts await prisma.category.deleteMany({ where: { id: { in: [treeCategoryId].filter(Boolean) } } }); await prisma.country.deleteMany({ where: { id: { in: [treeCountryId].filter(Boolean) } } }); ``` (`origin_goods` 的 A/B 由现有 `createdSds` 机制外的 `sdsGoodId` 建的,需把 `tree-a-${stamp}`/`tree-b-${stamp}` 也 push 进 `createdSds` 以复用现有清理。) - [ ] **Step 2: 运行确认失败** ```bash pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts ``` 预期:FAIL(B 的 configuredCount 为 0)。 - [ ] **Step 3: 实现 getTree() 合并统计** `Promise.all` 查询数组追加两个副源查询: ```ts this.prisma.goodOriginGood.groupBy({ by: ['originGoodId'], _count: { _all: true }, }), this.prisma.goodOriginGood.findMany({ select: { originGoodId: true, good: { select: { country: { select: { countryName: true } } } }, }, }), ``` 解构对应加 `mergedCounts, mergedWithCountries`。合并逻辑: ```ts const countMap = new Map(); configCounts.forEach((c) => countMap.set(c.originGoodId.toString(), c._count._all), ); mergedCounts.forEach((c) => { const key = c.originGoodId.toString(); countMap.set(key, (countMap.get(key) ?? 0) + c._count._all); }); ``` `countryMap` 构建循环后追加: ```ts mergedWithCountries.forEach((m) => { const key = m.originGoodId.toString(); const name = m.good.country?.countryName; if (!name) return; const arr = countryMap.get(key); if (arr) { if (!arr.includes(name)) arr.push(name); } else { countryMap.set(key, [name]); } }); ``` `tagMap`:现有 `goodTag.findMany` 的 `good` select 加 `mergedOriginGoods: { select: { originGoodId: true } }`,遍历时对每个 `gt.good.mergedOriginGoods` 的 `originGoodId` 重复同样的 tagInfo 写入(复用现有去重逻辑,抽个局部 `addTag(key, tagInfo)` helper)。 - [ ] **Step 4: 运行测试通过** ```bash pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts ``` 预期:PASS。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/origin-goods git commit -m "feat(origin-goods): include merged references in tree stats" ``` ### Task 6: public — 副源入口 + 变体/图组合并 **Files:** - Modify: `apps/api/src/public/public.service.ts` - Test: `apps/api/src/public/public.service.spec.ts` - [ ] **Step 1: 写失败测试(追加到该文件,直接用 prisma 建 fixture,与现有用例同模式)** ```ts it('resolves a good by secondary sdsGoodId with merged variants', async () => { const secondary = await prisma.originGood.create({ data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` }, }); await prisma.originGoodVariant.create({ data: { originGoodId: originGoodId, sdsVariantId: `pub-var-pri-${stamp}`, sku: `PUB-PRI-${stamp}`, colorId: 'black', colorName: '黑色', colorHex: '#000000', imageUrl: 'http://img/pri', }, }); await prisma.originGoodVariant.create({ data: { originGoodId: secondary.id, sdsVariantId: `pub-var-sec-${stamp}`, sku: `PUB-SEC-${stamp}`, colorId: 'black', colorName: '黑色', colorHex: '#000000', imageUrl: 'http://img/sec', }, }); const mergedGood = await prisma.good.create({ data: { goodName: `Pub Merged ${stamp}`, originGoodId, countryId, categoryId, }, }); await prisma.goodOriginGood.create({ data: { goodId: mergedGood.id, originGoodId: secondary.id }, }); const detail = await service.getGood(`pub-secondary-${stamp}`); expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源 sdsGoodId expect(detail.variants.length).toBe(2); const black = detail.mediaByColor.find((g) => g.colorName === '黑色'); expect(black).toBeTruthy(); expect(black.images.length).toBe(2); // 两源同色图片合到一组 await prisma.good.delete({ where: { id: mergedGood.id } }); await prisma.originGoodVariant.deleteMany({ where: { sdsVariantId: { in: [`pub-var-pri-${stamp}`, `pub-var-sec-${stamp}`] } }, }); await prisma.originGood.delete({ where: { id: secondary.id } }); }); ``` (`pub-sds-${stamp}` / `originGoodId` / `countryId` / `categoryId` 复用该文件 `beforeAll` 已建的 fixture;`pub-sds` 对应的 originGood 可能已有变体 fixture,断言 `variants.length` 时以两源新增变体计数为准,若已有变体则改为 `toBeGreaterThanOrEqual(2)` 并断言包含两个 `sku`。) - [ ] **Step 2: 运行确认失败** ```bash pnpm --filter @inkreach/api test -- public.service.spec.ts ``` 预期:FAIL(NotFoundException,副源 id 查不到)。 - [ ] **Step 3: 实现** `PUBLIC_GOOD_INCLUDE` 加: ```ts mergedOriginGoods: { include: { originGood: { include: { variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, }, }, }, ``` `getGood()` 的 `where` 改为: ```ts const good = await this.prisma.good.findFirst({ where: { OR: [ { originGood: { sdsGoodId: goodId, delisted: false } }, { mergedOriginGoods: { some: { originGood: { sdsGoodId: goodId, delisted: false } } } }, ], }, ... ``` `toPublicGoodDetail()` 开头聚合全部变体: ```ts const allVariants = [ ...good.originGood.variants, ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants), ]; ``` `mediaByColor: this.groupImagesByColor(allVariants)`,`variants: allVariants.map((variant) => ({ ... }))`(沿用现有字段映射,不加来源字段保持小程序兼容)。 - [ ] **Step 4: 运行测试通过** ```bash pnpm --filter @inkreach/api test -- public.service.spec.ts ``` 预期:PASS。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/public git commit -m "feat(public): resolve goods by secondary sds id and merge variants" ``` ### Task 7: admin — vitest 基建 + 名称匹配纯函数 **Files:** - Modify: `apps/admin/package.json`(scripts 加 test,devDeps 加 vitest) - Create: `apps/admin/src/utils/origin-name.ts` - Create: `apps/admin/src/utils/origin-name.spec.ts` - [ ] **Step 1: 安装 vitest** ```bash pnpm --filter @inkreach/admin add -D vitest ``` `apps/admin/package.json` scripts 加: ```json "test": "vitest run" ``` - [ ] **Step 2: 写失败测试 `origin-name.spec.ts`** ```ts import { describe, expect, it } from 'vitest'; import { sameOriginGroup, truncateToProcess } from './origin-name'; describe('truncateToProcess', () => { it('keeps first 3 dash segments (drops warehouse)', () => { expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓')) .toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花'); }); it('is identity for 3-segment names', () => { expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面')) .toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'); }); it('handles malformed names without country prefix', () => { expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花')) .toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'); }); it('returns empty for null/empty', () => { expect(truncateToProcess(null)).toBe(''); expect(truncateToProcess('')).toBe(''); }); }); describe('sameOriginGroup', () => { it('matches same product across warehouses', () => { expect(sameOriginGroup( '美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓', '美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓', )).toBe(true); }); it('does not match different SKU', () => { expect(sameOriginGroup( '美国(包邮)180g纯棉T恤成人款-DG001-单面印花', '美国(包邮)180g纯棉T恤成人款-DG002-单面印花', )).toBe(false); }); it('does not match different country', () => { expect(sameOriginGroup( '美国(包邮)T恤-DG001-单面印花', '德国(不包邮)T恤-DG001-单面印花', )).toBe(false); }); }); ``` - [ ] **Step 3: 运行确认失败** ```bash pnpm --filter @inkreach/admin test ``` 预期:FAIL(模块不存在)。 - [ ] **Step 4: 实现 `origin-name.ts`** ```ts /** * 原产品名匹配规则(见 data-cleaning/README.md 命名调查): * `国家(物流备注)品名-SKU-工艺位置[-仓库名]` * 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。 * 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。 */ const KEEP_SEGMENTS = 3; export function truncateToProcess(name: string | null | undefined): string { if (!name) return ''; return name.split('-').slice(0, KEEP_SEGMENTS).join('-'); } export function sameOriginGroup( a: string | null | undefined, b: string | null | undefined, ): boolean { const ka = truncateToProcess(a); return ka !== '' && ka === truncateToProcess(b); } ``` - [ ] **Step 5: 运行测试通过 + 构建不破** ```bash pnpm --filter @inkreach/admin test pnpm --filter @inkreach/admin build ``` 预期:test PASS;build 通过(spec 文件被 vue-tsc 检查但显式 import vitest 类型可解析;若 build 报 unused/类型错则修 spec 或在 `tsconfig.app.json` 的 include 处理)。 - [ ] **Step 6: Commit** ```bash git add apps/admin/package.json apps/admin/src/utils pnpm-lock.yaml git commit -m "feat(admin): add origin name group matching util with vitest" ``` ### Task 8: admin — 配置弹窗兄弟多选 **Files:** - Modify: `apps/admin/src/types/index.ts` - Modify: `apps/admin/src/views/goods/GoodsView.vue` - 验证:`pnpm --filter @inkreach/admin build`(组件无测试设施,靠类型检查 + 手动验证) - [ ] **Step 1: 类型** `types/index.ts`: ```ts export interface MergedOriginGoodSummary { id: string sdsGoodId: string goodName: string | null goodImage: string | null goodPrice: string | null hasDetail: boolean variantCount: number } ``` `Good` 加 `mergedOriginGoods?: MergedOriginGoodSummary[]`;`CreateGoodRequest` 加 `mergedOriginGoodIds?: number[]`;`UpdateGoodRequest` 加 `mergedOriginGoodIds?: number[]`。 - [ ] **Step 2: buildRightTree 给 og 节点带 `parentId`** `buildRightTree` 的 `mapCat` 里 og 节点对象加一行: ```ts parentId: 'rc-' + node.categoryId, ``` - [ ] **Step 3: 配置弹窗状态与逻辑** `GoodsView.vue` script: ```ts import { sameOriginGroup } from '@/utils/origin-name' const configSiblings = ref([]) // 同分类未配置兄弟 const configChecked = ref([]) // 勾选的副源 rawId const configPrimaryId = ref('') // 主源 rawId function collectSiblings(ogNode: any): any[] { const result: any[] = [] function traverse(nodes: any[]) { for (const n of nodes) { if (n.isOG && n.parentId === ogNode.parentId && n.rawId !== ogNode.rawId && !n.configuredCount) result.push(n) if (n.children?.length) traverse(n.children) } } traverse(rightTreeData.value) return result } function openConfigModal(og: any, dropTarget: any) { // ...现有逻辑不变,末尾追加: const ogNode = findOgNodeById(og.rawId) const siblings = ogNode ? collectSiblings(ogNode) : [] configSiblings.value = siblings configPrimaryId.value = String(og.rawId) configChecked.value = siblings .filter((s) => sameOriginGroup(og.goodName, s.goodName)) .map((s) => String(s.rawId)) } function findOgNodeById(rawId: string | number): any { let found: any = null function traverse(nodes: any[]) { for (const n of nodes) { if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return } if (n.children?.length) traverse(n.children) } } traverse(rightTreeData.value) return found } ``` `handleConfigSubmit` 的 `goodsApi.createGood` 参数中 `originGoodId` 改用主源 radio 值,并追加副源: ```ts originGoodId: Number(configPrimaryId.value || configOG.value.rawId), mergedOriginGoodIds: configChecked.value .filter((id) => id !== configPrimaryId.value) .map(Number), ``` 主源与副源的关系:主源 = `configPrimaryId`(radio 值),副源 = `configChecked` 中排除主源后的项。checkbox 列表不含当前主源项,radio 切到某个兄弟后它自动从副源提交中排除。 - [ ] **Step 4: 配置弹窗模板** `配置原产品` dialog 的 `el-form` 内、「图片」form-item 之前插入: ```html
勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。
主源:{{ configOG.goodName }} {{ s.goodName }}
{{ s.goodName }}
``` 配套 computed: ```ts const checkedSiblingNodes = computed(() => configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))), ) ``` - [ ] **Step 5: 构建验证 + 手动验证** ```bash pnpm --filter @inkreach/admin build ``` 手动(dev 起后台后):右栏点一个带同名兄弟的商品「配置」→ 弹窗出现勾选列表且同名项已勾 → 提交后左栏 1 条、右栏同组全部已配置。 - [ ] **Step 6: Commit** ```bash git add apps/admin/src git commit -m "feat(admin): suggest and merge sibling origin goods in config modal" ``` ### Task 9: admin — 编辑弹窗关联原产品管理 **Files:** - Modify: `apps/admin/src/views/goods/GoodsView.vue` - [ ] **Step 1: 编辑弹窗状态** GoodsView.vue 的 types import 中补 `MergedOriginGoodSummary`(该文件已从 `@/types` import 多个类型,追加到现有 import 列表)。 ```ts const editMerged = ref([]) const editMergeSearch = ref('') const originalOriginGoodId = ref('') // openEdit 时记录初始主源,提交时判断是否切换 // 候选 = 右栏树全部 og(含已配置,用于存量合并),按搜索词过滤 const editMergeCandidates = computed(() => { if (!editMergeSearch.value) return [] const kw = editMergeSearch.value const result: any[] = [] function traverse(nodes: any[]) { for (const n of nodes) { if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n) if (n.children?.length) traverse(n.children) } } traverse(rightTreeData.value) return result.filter( (n) => n.rawId !== editGood.value?.originGoodId && !editMerged.value.some((m) => m.id === String(n.rawId)), ) }) ``` `openEdit` 里初始化(`editGood.value = g` 之后): ```ts editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? [] originalOriginGoodId.value = g.originGoodId ``` 详情加载完成后(`editGood.value = detail` 之后)覆盖一次:`editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []`。 - [ ] **Step 2: 编辑弹窗模板** 编辑弹窗「关联原产品」区(`edit-og-ref` div 之后,非 custom 商品时显示): ```html
关联原产品(主源 + 副源)
{{ editGood?.originGood?.goodName }}
{{ m.goodName }} 设为主源 移除
``` 配套方法: ```ts function addEditMerge(node: any) { editMerged.value.push({ id: String(node.rawId), sdsGoodId: node.sdsGoodId, goodName: node.goodName, goodImage: node.goodImage, goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail), variantCount: node.variantCount ?? 0, }) editMergeSearch.value = '' } function promoteMerged(m: MergedOriginGoodSummary) { if (!editGood.value) return const oldPrimaryId = editGood.value.originGoodId const oldPrimary = editGood.value.originGood editGood.value = { ...editGood.value, originGoodId: m.id, originGood: { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName }, } as any editMerged.value = [ { id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null, goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null, hasDetail: oldPrimary?.hasDetail ?? false, variantCount: oldPrimary?.variantCount ?? 0 }, ...editMerged.value.filter((x) => x.id !== m.id), ] } ``` `handleEditSubmit` 的非 custom 分支 payload 追加: ```ts originGoodId: editGood.value?.originGoodId !== originalOriginGoodId ? Number(editGood.value!.originGoodId) : undefined, mergedOriginGoodIds: editMerged.value.map((m) => Number(m.id)), ``` (`originalOriginGoodId` 在 `openEdit` 时记录初始主源 id。) - [ ] **Step 3: 构建验证 + 手动验证** ```bash pnpm --filter @inkreach/admin build ``` 手动:编辑弹窗搜索添加副源 → 保存 → 重新打开确认副源在列表;「设为主源」切换后保存 → 详情价格/详情以新主源为准、变体数量增加。 - [ ] **Step 4: Commit** ```bash git add apps/admin/src git commit -m "feat(admin): manage merged origin goods in good edit modal" ``` ### Task 10: admin — 左栏合并角标 **Files:** - Modify: `apps/admin/src/views/goods/GoodsView.vue` - [ ] **Step 1: goodToNode 加计数** ```ts mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0), ``` - [ ] **Step 2: 模板角标** 左栏商品节点 label 区域(`goodToNode` 渲染处)加: ```html ×{{ data.mergedCount }} ``` 样式(style 区): ```scss .gv-merged-badge { margin-left: 4px; padding: 0 5px; border-radius: 8px; background: var(--el-color-primary-light-8); color: var(--el-color-primary); font-size: 11px; } ``` - [ ] **Step 3: 构建 + Commit** ```bash pnpm --filter @inkreach/admin build git add apps/admin/src git commit -m "feat(admin): show merged count badge on good nodes" ``` ### Task 11: 全量回归 + 文档更新 **Files:** - Modify: `docs/references/structs.md` - Modify: `docs/references/` 下商品相关文档(如 `product-center.md`) - Modify: `README.md`(如含配置流程说明) - Modify: `skills/inkreach/SKILL.md` - [ ] **Step 1: 全量测试与构建** ```bash pnpm --filter @inkreach/api test pnpm --filter @inkreach/api build pnpm --filter @inkreach/admin test pnpm --filter @inkreach/admin build ``` 预期:全部通过。任何现有测试失败必须当场修复。 - [ ] **Step 2: 更新文档** - `docs/references/structs.md`:新增 `good_origin_goods` 表说明(模块职责一行)。 - 商品参考文档:`POST /goods` / `PATCH /goods/:id` 的 `mergedOriginGoodIds` 参数说明 + 示例;配置弹窗合并同名操作说明;编辑弹窗关联管理说明;public 详情副源 sdsGoodId 可达说明。 - `skills/inkreach/SKILL.md`:同步补充接口参数变更。 - [ ] **Step 3: Commit** ```bash git add docs skills README.md git commit -m "docs: document merged origin goods feature" ``` - [ ] **Step 4: 按 enterprise-git-spec 合并回 develop** 全绿后走企业 Git 规范完成合并。