50 KiB
原产品多对一合并(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)。
当前左右配置功能:右栏(原产品库)点「配置」→ POST /goods 创建左栏商品,goods.origin_good_id 是单值非空外键,一个 Good 只能挂一个原产品。由此产生两个问题:
- 同名原产品(不同工厂/颜色/价格)只能各配各的 → 左栏出现多条同名商品,小程序端(
public模块)不能给用户看到重复商品。 - 只配一条时,右栏另外几条同名原产品仍显示「未配置」,按钮还挂着。
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/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 命名调查):
- 格式:
国家(物流备注)品名-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 批量脚本)零改动可继续运行;后续脚本可增量传副源。- 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)加反向关系:
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
预期:FAIL(TS 编译报 mergedOriginGoodIds 不存在于 DTO)。
- Step 3: DTO 加字段
create-good.dto.ts、update-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.ts 的 BatchCreateItemDto 同样加该字段。
- 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 })),
});
}
GoodDto(good.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 goodsdescribe 内)
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
预期:FAIL(update 忽略 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
预期:FAIL(variants 无 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
预期:FAIL(B 的 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.findMany 的 good select 加 mergedOriginGoods: { select: { originGoodId: true } },遍历时对每个 gt.good.mergedOriginGoods 的 originGoodId 重复同样的 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 已建的 fixture;pub-sds 对应的 originGood 可能已有变体 fixture,断言 variants.length 时以两源新增变体计数为准,若已有变体则改为 toBeGreaterThanOrEqual(2) 并断言包含两个 sku。)
- Step 2: 运行确认失败
pnpm --filter @inkreach/api test -- public.service.spec.ts
预期:FAIL(NotFoundException,副源 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.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
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 PASS;build 通过(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
}
Good 加 mergedOriginGoods?: MergedOriginGoodSummary[];CreateGoodRequest 加 mergedOriginGoodIds?: number[];UpdateGoodRequest 加 mergedOriginGoodIds?: number[]。
- Step 2: buildRightTree 给 og 节点带
parentId
buildRightTree 的 mapCat 里 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
}
handleConfigSubmit 的 goodsApi.createGood 参数中 originGoodId 改用主源 radio 值,并追加副源:
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 之前插入:
<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)),
(originalOriginGoodId 在 openEdit 时记录初始主源 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/:id的mergedOriginGoodIds参数说明 + 示例;配置弹窗合并同名操作说明;编辑弹窗关联管理说明;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 规范完成合并。