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

50 KiB
Raw Blame History

原产品多对一合并(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_goodssdsGoodId 唯一 upsert,存在多条名称几乎相同、仅尾部(工厂/仓库)不同的原产品(实测 552 条中,国家(物流备注)品名-SKU-工艺位置[-仓库名] 格式里 154 条带第 4 段仓库名,详见 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 表零改动):

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 / originGoodNamevariantCount 为合并总数。

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()variantsmediaByColor 合并主源 + 副源全部变体;groupImagesByColor 按颜色归组,同色图片自然合并。
  • price / detail / 排序 / 价格筛选 / 上下架过滤:仍以主源为准(已确认决策)。
  • 列表接口(getGoods / getHomeGoods 等):不返回变体明细,除 where.originGood.delisted 语义不变外无改动。

6. 前端改动(apps/adminGoodsView.vue

6.1 配置弹窗增强(新配置流程)

  • 打开弹窗时列出右栏树同分类(同父节点)下的兄弟原产品
  • 自动勾选规则:名称截断到「工艺位置」段后相同的兄弟项。截断规则(来自 data-cleaning/README.md 命名调查):
    • 格式:国家(物流备注)品名-SKU-工艺位置[-仓库名]之后按 - 分段,保留前 3 段(品名-SKU-工艺),丢弃可选的第 4 段仓库名。
    • 匹配 key = 保留的 3 段(或不足 3 段时取全部);截断逻辑抽成独立纯函数 + 单测,规则常量集中定义便于调整。
    • 11 条异常命名(缺国家前缀、半角括号等)匹配不上就不自动勾选,可手动勾。
  • 主源默认 = 点击「配置」的那个,列表中标记「主」;可手动改选主源。
  • 提交 POST /goodsmergedOriginGoodIds(不含主源)。
  • 匹配项默认展开高亮;未匹配兄弟项收入搜索框之后手动勾选。

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 /goodsmergedOriginGoodIds 可选,现有调用方(含 data-cleaning/sync-apply.mjs 批量脚本)零改动可继续运行;后续脚本可增量传副源。
  • public DTO 只增字段不改既有字段,小程序端向后兼容。
  • 部署顺序:先跑 migration(新表,无破坏性),再发 API,最后发 admin 前端。

10. 风险与边界

  • 名称截断规则基于 2026-08-27 生产快照调查(541/552 匹配主格式),规则抽成常量 + 纯函数,便于按新数据调整。
  • 合并组变体数量可能较大(N 个工厂 × 各自 SKU),详情响应体量增大;列表不受影响。
  • 主源唯一决定可见性/价格/详情 → 运营需知晓「换主源」的影响面(编辑弹窗中提示文案说明)。

实施计划

测试运行方式:API 集成测试直连本地库(apps/api/.envDATABASE_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)加反向关系:

  goods           Good[]
  detail          OriginGoodDetail?
  variants        OriginGoodVariant[]
  mergedIntoGoods GoodOriginGood[]

Good 模型(约 L217)加反向关系:

  goodTags   GoodTag[]
  mergedOriginGoods GoodOriginGood[]

GoodTag 模型之后新增:

// ---------- 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
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 应等价于:

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: 验证
pnpm --filter @inkreach/api exec prisma validate
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:validate 通过;现有测试全绿(新表为空,不影响存量行为)。

  • Step 4: Commit
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 前的用例区)

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: 运行确认失败
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:FAILTS 编译报 mergedOriginGoodIds 不存在于 DTO)。

  • Step 3: DTO 加字段

create-good.dto.tsupdate-good.dto.ts 各加(放在 originGoodId 字段之后):

  @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
  @IsOptional()
  @IsArray()
  @IsInt({ each: true })
  @Min(1, { each: true })
  mergedOriginGoodIds?: number[];

batch-create-good.dto.tsBatchCreateItemDto 同样加该字段。

  • Step 4: service 实现

goods.service.ts

GOOD_INCLUDE 加副源(goodTags 行之后):

  goodTags: { include: { tag: true } },
  mergedOriginGoods: {
    include: {
      originGood: {
        include: {
          detail: true,
          variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
          _count: { select: { variants: true } },
        },
      },
    },
  },

新增两个私有方法(ensureOriginGood 附近):

  /** 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 之后、事务之前:

    const mergedIds = this.dedupeMergedIds(BigInt(dto.originGoodId), dto.mergedOriginGoodIds);
    await this.ensureMergedOriginGoods(mergedIds);

事务内 goodTag.createMany 之后:

      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() 写入副源:

      // 事务内、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 })),
        });
      }

GoodDtogood.dto.ts):GoodRelations 接口加:

  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 之后):

  @ApiProperty({ required: false, type: Array })
  mergedOriginGoods!: Array<{
    id: string;
    sdsGoodId: string;
    goodName: string | null;
    goodImage: string | null;
    goodPrice: string | null;
    hasDetail: boolean;
    variantCount: number;
  }>;
      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: 运行测试通过
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:PASS。

  • Step 6: Commit
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 内)

  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: 运行确认失败
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:FAILupdate 忽略 mergedOriginGoodIds)。

  • Step 3: 实现 update()

update() 中,在 await this.findOne(id); 之后加:

    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 覆盖块之后加:

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

事务结果返回后,把详情同步触发扩展为副源也触发:

    const syncTargets = [
      result.originGood,
      ...result.mergedOriginGoods.map((m) => m.originGood),
    ].filter((og) => og?.source === 'SDS' && og.sdsGoodId && !og.hasDetail);

create()/batchCreate() 的同步触发同样改用该模式。)

  • Step 4: 运行测试通过
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:PASS。

  • Step 5: Commit
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: 写失败测试

  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: 运行确认失败
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:FAILvariants 无 originGoodId 字段)。

  • Step 3: 实现 GoodDetailDto.fromGood 合并变体
  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: 运行测试通过
pnpm --filter @inkreach/api test -- goods.service.spec.ts

预期:PASS。

  • Step 5: Commit
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

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

新用例:

  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 追加清理:

    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: 运行确认失败
pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts

预期:FAILB 的 configuredCount 为 0)。

  • Step 3: 实现 getTree() 合并统计

Promise.all 查询数组追加两个副源查询:

        this.prisma.goodOriginGood.groupBy({
          by: ['originGoodId'],
          _count: { _all: true },
        }),
        this.prisma.goodOriginGood.findMany({
          select: {
            originGoodId: true,
            good: { select: { country: { select: { countryName: true } } } },
          },
        }),

解构对应加 mergedCounts, mergedWithCountries。合并逻辑:

    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 构建循环后追加:

    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.findManygood select 加 mergedOriginGoods: { select: { originGoodId: true } },遍历时对每个 gt.good.mergedOriginGoodsoriginGoodId 重复同样的 tagInfo 写入(复用现有去重逻辑,抽个局部 addTag(key, tagInfo) helper)。

  • Step 4: 运行测试通过
pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts

预期:PASS。

  • Step 5: Commit
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,与现有用例同模式)

  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 已建的 fixturepub-sds 对应的 originGood 可能已有变体 fixture,断言 variants.length 时以两源新增变体计数为准,若已有变体则改为 toBeGreaterThanOrEqual(2) 并断言包含两个 sku。)

  • Step 2: 运行确认失败
pnpm --filter @inkreach/api test -- public.service.spec.ts

预期:FAILNotFoundException,副源 id 查不到)。

  • Step 3: 实现

PUBLIC_GOOD_INCLUDE 加:

  mergedOriginGoods: {
    include: {
      originGood: {
        include: {
          variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
        },
      },
    },
  },

getGood()where 改为:

    const good = await this.prisma.good.findFirst({
      where: {
        OR: [
          { originGood: { sdsGoodId: goodId, delisted: false } },
          { mergedOriginGoods: { some: { originGood: { sdsGoodId: goodId, delisted: false } } } },
        ],
      },
      ...

toPublicGoodDetail() 开头聚合全部变体:

    const allVariants = [
      ...good.originGood.variants,
      ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants),
    ];

mediaByColor: this.groupImagesByColor(allVariants)variants: allVariants.map((variant) => ({ ... }))(沿用现有字段映射,不加来源字段保持小程序兼容)。

  • Step 4: 运行测试通过
pnpm --filter @inkreach/api test -- public.service.spec.ts

预期:PASS。

  • Step 5: Commit
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.jsonscripts 加 testdevDeps 加 vitest

  • Create: apps/admin/src/utils/origin-name.ts

  • Create: apps/admin/src/utils/origin-name.spec.ts

  • Step 1: 安装 vitest

pnpm --filter @inkreach/admin add -D vitest

apps/admin/package.json scripts 加:

    "test": "vitest run"
  • Step 2: 写失败测试 origin-name.spec.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: 运行确认失败
pnpm --filter @inkreach/admin test

预期:FAIL(模块不存在)。

  • Step 4: 实现 origin-name.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: 运行测试通过 + 构建不破
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
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

export interface MergedOriginGoodSummary {
  id: string
  sdsGoodId: string
  goodName: string | null
  goodImage: string | null
  goodPrice: string | null
  hasDetail: boolean
  variantCount: number
}

GoodmergedOriginGoods?: MergedOriginGoodSummary[]CreateGoodRequestmergedOriginGoodIds?: number[]UpdateGoodRequestmergedOriginGoodIds?: number[]

  • Step 2: buildRightTree 给 og 节点带 parentId

buildRightTreemapCat 里 og 节点对象加一行:

      parentId: 'rc-' + node.categoryId,
  • Step 3: 配置弹窗状态与逻辑

GoodsView.vue script

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
}

handleConfigSubmitgoodsApi.createGood 参数中 originGoodId 改用主源 radio 值,并追加副源:

      originGoodId: Number(configPrimaryId.value || configOG.value.rawId),
      mergedOriginGoodIds: configChecked.value
        .filter((id) => id !== configPrimaryId.value)
        .map(Number),

主源与副源的关系:主源 = configPrimaryIdradio 值),副源 = configChecked 中排除主源后的项。checkbox 列表不含当前主源项,radio 切到某个兄弟后它自动从副源提交中排除。

  • Step 4: 配置弹窗模板

配置原产品 dialog 的 el-form 内、「图片」form-item 之前插入:

        <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

const checkedSiblingNodes = computed(() =>
  configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))),
)
  • Step 5: 构建验证 + 手动验证
pnpm --filter @inkreach/admin build

手动(dev 起后台后):右栏点一个带同名兄弟的商品「配置」→ 弹窗出现勾选列表且同名项已勾 → 提交后左栏 1 条、右栏同组全部已配置。

  • Step 6: Commit
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 列表)。

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 之后):

  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 商品时显示):

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

配套方法:

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 追加:

      originGoodId: editGood.value?.originGoodId !== originalOriginGoodId ? Number(editGood.value!.originGoodId) : undefined,
      mergedOriginGoodIds: editMerged.value.map((m) => Number(m.id)),

originalOriginGoodIdopenEdit 时记录初始主源 id。)

  • Step 3: 构建验证 + 手动验证
pnpm --filter @inkreach/admin build

手动:编辑弹窗搜索添加副源 → 保存 → 重新打开确认副源在列表;「设为主源」切换后保存 → 详情价格/详情以新主源为准、变体数量增加。

  • Step 4: Commit
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 加计数

    mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0),
  • Step 2: 模板角标

左栏商品节点 label 区域(goodToNode 渲染处)加:

<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>

样式(style 区):

.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
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: 全量测试与构建

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/:idmergedOriginGoodIds 参数说明 + 示例;配置弹窗合并同名操作说明;编辑弹窗关联管理说明;public 详情副源 sdsGoodId 可达说明。

  • skills/inkreach/SKILL.md:同步补充接口参数变更。

  • Step 3: Commit

git add docs skills README.md
git commit -m "docs: document merged origin goods feature"
  • Step 4: 按 enterprise-git-spec 合并回 develop

全绿后走企业 Git 规范完成合并。