diff --git a/plans/feature/multi-origin-good-merge-feature.md b/plans/feature/multi-origin-good-merge-feature.md new file mode 100644 index 0000000..3d86b32 --- /dev/null +++ b/plans/feature/multi-origin-good-merge-feature.md @@ -0,0 +1,1422 @@ +# 原产品多对一合并(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 规范完成合并。 +