Files
inkreach-official-website/plans/feature/multi-origin-good-merge-feature.md
T

1423 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 原产品多对一合并(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、PrismaPostgreSQL)、JestAPI 集成测试)、Vue3 + Element Plus、Vitestadmin 纯函数测试)、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/adminGoodsView.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/<timestamp>_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
```
预期:FAILTS 编译报 `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
```
预期:FAILupdate 忽略 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<string, unknown>).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<string, unknown>).originGoodId === originGoodIds[1].toString()).length,
);
});
```
- [ ] **Step 2: 运行确认失败**
```bash
pnpm --filter @inkreach/api test -- goods.service.spec.ts
```
预期:FAILvariants 无 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<GoodRelations['originGood']>['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
```
预期:FAILB 的 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<string, number>();
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
```
预期:FAILNotFoundException,副源 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 加 testdevDeps 加 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 PASSbuild 通过(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<any[]>([]) // 同分类未配置兄弟
const configChecked = ref<string[]>([]) // 勾选的副源 rawId
const configPrimaryId = ref<string>('') // 主源 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
<el-form-item v-if="configSiblings.length" label="合并同名">
<div class="config-merge-box">
<div class="config-merge-tip">勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。</div>
<div class="config-merge-primary">
<el-radio-group v-model="configPrimaryId">
<el-radio :value="String(configOG.rawId)">主源:{{ configOG.goodName }}</el-radio>
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
</el-radio-group>
</div>
<el-checkbox-group v-model="configChecked">
<el-checkbox v-for="s in configSiblings" :key="s.rawId" :value="String(s.rawId)">
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
</el-checkbox>
</el-checkbox-group>
</div>
</el-form-item>
```
配套 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<MergedOriginGoodSummary[]>([])
const editMergeSearch = ref('')
const originalOriginGoodId = ref<string>('') // 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
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品(主源 + 副源)
<el-tooltip content="主源决定价格、详情与上下架;副源变体合并展示。切换主源后旧主源自动转为副源。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="edit-merged-list">
<div class="edit-merged-item primary">
<span class="edit-merged-tag"></span>
<span>{{ editGood?.originGood?.goodName }}</span>
</div>
<div v-for="m in editMerged" :key="m.id" class="edit-merged-item">
<span class="edit-merged-tag sub"></span>
<span>{{ m.goodName }}</span>
<el-button size="small" link type="primary" @click="promoteMerged(m)">设为主源</el-button>
<el-button size="small" link type="danger" @click="editMerged = editMerged.filter(x => x.id !== m.id)">移除</el-button>
</div>
</div>
<el-select v-model="editMergeSearch" filterable remote :remote-method="(q: string) => editMergeSearch = q"
placeholder="搜索原产品名称以添加副源(用于合并同名商品)" clearable style="width:100%">
<el-option v-for="c in editMergeCandidates" :key="c.rawId" :label="c.goodName" :value="String(c.rawId)"
@click="addEditMerge(c)" />
</el-select>
</div>
```
配套方法:
```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
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
```
样式(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 规范完成合并。