# 原产品多对一合并（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/<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
```

预期：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<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
```

预期：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<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
```

预期：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<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
```

预期：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<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 规范完成合并。

