1423 lines
50 KiB
Markdown
1423 lines
50 KiB
Markdown
# 原产品多对一合并(Multi Origin Good Merge)设计与实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
>
|
||
> 前置:按 enterprise-git-spec 建分支 `feature/multi-origin-good-merge`。
|
||
|
||
**Goal:** 左栏一个 Good 关联多个原产品(1 主源 + N 副源),变体合并返回,右栏统计正确,小程序副源入口可用,存量数据零迁移。
|
||
|
||
**Architecture:** 新增 `good_origin_goods` 中间表存副源(主源不入表,走现有 `goods.origin_good_id` 外键);admin/public/origin-goods 三个 API 模块在读写处合并"主源 ∪ 副源"两个来源;前端配置弹窗按名称截断规则自动勾选同分类兄弟原产品。
|
||
|
||
**Tech Stack:** NestJS、Prisma(PostgreSQL)、Jest(API 集成测试)、Vue3 + Element Plus、Vitest(admin 纯函数测试)、pnpm + turbo。
|
||
|
||
## 1. 背景与问题
|
||
|
||
上游 SDS 同步的 `origin_goods` 按 `sdsGoodId` 唯一 upsert,存在**多条名称几乎相同、仅尾部(工厂/仓库)不同**的原产品(实测 552 条中,`国家(物流备注)品名-SKU-工艺位置[-仓库名]` 格式里 154 条带第 4 段仓库名,详见 [data-cleaning/README.md](../../data-cleaning/README.md))。
|
||
|
||
当前左右配置功能:右栏(原产品库)点「配置」→ `POST /goods` 创建左栏商品,`goods.origin_good_id` 是**单值非空外键**,一个 Good 只能挂一个原产品。由此产生两个问题:
|
||
|
||
1. 同名原产品(不同工厂/颜色/价格)只能各配各的 → 左栏出现多条同名商品,小程序端(`public` 模块)不能给用户看到重复商品。
|
||
2. 只配一条时,右栏另外几条同名原产品仍显示「未配置」,按钮还挂着。
|
||
|
||
## 2. 目标
|
||
|
||
- **左栏一个 Good 可关联多个原产品**:1 主源(现有外键)+ N 副源(新中间表)。
|
||
- **变体合并**:查 Good 时主源 + 副源的 `origin_good_variants` 一次全部带回,全部拼接不去重,靠来源字段区分。
|
||
- **右栏统计正确**:被主源或副源引用过的原产品都显示已配置。
|
||
- **小程序详情入口不失效**:副源的 `sdsGoodId` 也能查到合并后的 Good。
|
||
- **存量数据无损**:现有 1:1 关系零迁移,重复的存量 Good 通过后台手动合并清理。
|
||
|
||
## 3. 方案对比与选择
|
||
|
||
| 方案 | 结论 | 原因 |
|
||
|---|---|---|
|
||
| A. 中间表 `good_origin_goods` | ✅ 采用 | 真外键 + Cascade 引用完整;Prisma 原生 relation 可 include;统计查询干净(项目已有 `good_tags` 同构先例) |
|
||
| B. `goods` 表加 `bigint[]` 数组列 | ❌ | PostgreSQL 数组列不能建外键,会产生悬挂 id;Prisma 只能当裸标量,无关联语义 |
|
||
| C. 同步/清洗层直接合并 OriginGood | ❌ | 丢失各工厂独立变体;每次同步要维护合并规则 |
|
||
| D. OriginGood 加指向 Good 的外键 | ❌ | 破坏现有「一个原产品配到多个国家/分类」的 1:N 能力 |
|
||
|
||
**关键设计:主源不写入中间表。** 主源永远走 `goods.origin_good_id`(原样保留),中间表只存副源。避免同一关系存两处、换主源时要双向同步。
|
||
|
||
## 4. 数据库设计
|
||
|
||
新增表(`goods` 表零改动):
|
||
|
||
```prisma
|
||
model GoodOriginGood {
|
||
goodId BigInt @map("good_id")
|
||
originGoodId BigInt @map("origin_good_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||
|
||
@@id([goodId, originGoodId])
|
||
@@index([originGoodId])
|
||
@@map("good_origin_goods")
|
||
}
|
||
```
|
||
|
||
- 复合主键 `(good_id, origin_good_id)`,无自增 id。
|
||
- 双向 `Cascade`:删 Good 或删 OriginGood 自动清理关联行。
|
||
- `Good` / `OriginGood` 模型各加反向 `mergedOriginGoods GoodOriginGood[]` / `mergedIntoGoods GoodOriginGood[]`。
|
||
- 迁移:仅 `CREATE TABLE` + 外键;存量 1:1 数据不需要回填(主源不在表内)。
|
||
|
||
关系示意:
|
||
|
||
```
|
||
goods #100 ── origin_good_id(主源,现有外键)──────────→ origin_goods #1
|
||
│
|
||
└── good_origin_goods(副源,新表)
|
||
├── (100, 2) → origin_goods #2
|
||
└── (100, 3) → origin_goods #3
|
||
```
|
||
|
||
## 5. 后端改动(apps/api)
|
||
|
||
### 5.1 goods 模块(admin 接口)
|
||
|
||
- `CreateGoodDto` / `UpdateGoodDto` / `BatchCreateGoodDto`:新增可选 `mergedOriginGoodIds?: number[]`(副源,不包含主源)。
|
||
- `goods.service.ts`:
|
||
- `create()` / `batchCreate()`:事务内校验副源存在且不等于主源、去重后 `createMany` 写入。
|
||
- `update()`:`mergedOriginGoodIds` 全量覆盖(deleteMany + createMany);换主源 `originGoodId` 时若旧主源不在新的副源列表中,由前端保证传入(后端不做隐式迁移)。
|
||
- 详情同步触发:主源 + 副源中所有 `source=SDS && !hasDetail` 的都 `queueProductDetailSync`。
|
||
- `GOOD_INCLUDE` 增加 `mergedOriginGoods: { include: { originGood: { include: { detail: true, variants, _count } } } }`。
|
||
- `GoodDto` 增加 `mergedOriginGoods: Array<{ id, sdsGoodId, goodName, goodPrice, hasDetail, variantCount, ... }>` 摘要数组。
|
||
- `GoodDetailDto.variants`:主源 + 副源变体拼接,每条增加来源标注 `originGoodId` / `originGoodName`;`variantCount` 为合并总数。
|
||
|
||
### 5.2 origin-goods 模块(右栏树)
|
||
|
||
`getTree()` 的三个统计口径(`configuredCount` / `configuredCountries` / `configuredTags`)由「仅 goods 表 groupBy」改为「goods 表(主源)∪ good_origin_goods(副源)」两个查询合并。效果:合并组内全部同名原产品显示已配置。
|
||
|
||
### 5.3 public 模块(小程序接口)
|
||
|
||
- `getGood(sdsGoodId)`:`where` 改为 `OR: [{ originGood: { sdsGoodId } }, { mergedOriginGoods: { some: { originGood: { sdsGoodId } } } }]`(副源 id 也能命中同一 Good)。
|
||
- `toPublicGoodDetail()`:`variants` 与 `mediaByColor` 合并主源 + 副源全部变体;`groupImagesByColor` 按颜色归组,同色图片自然合并。
|
||
- `price` / `detail` / 排序 / 价格筛选 / 上下架过滤:**仍以主源为准**(已确认决策)。
|
||
- 列表接口(`getGoods` / `getHomeGoods` 等):不返回变体明细,除 `where.originGood.delisted` 语义不变外无改动。
|
||
|
||
## 6. 前端改动(apps/admin,GoodsView.vue)
|
||
|
||
### 6.1 配置弹窗增强(新配置流程)
|
||
|
||
- 打开弹窗时列出**右栏树同分类(同父节点)下的兄弟原产品**。
|
||
- 自动勾选规则:名称截断到「工艺位置」段后相同的兄弟项。截断规则(来自 [data-cleaning/README.md](../../data-cleaning/README.md) 命名调查):
|
||
- 格式:`国家(物流备注)品名-SKU-工艺位置[-仓库名]`,`)`之后按 `-` 分段,**保留前 3 段**(品名-SKU-工艺),丢弃可选的第 4 段仓库名。
|
||
- 匹配 key = 保留的 3 段(或不足 3 段时取全部);截断逻辑抽成独立纯函数 + 单测,规则常量集中定义便于调整。
|
||
- 11 条异常命名(缺国家前缀、半角括号等)匹配不上就不自动勾选,可手动勾。
|
||
- 主源默认 = 点击「配置」的那个,列表中标记「主」;可手动改选主源。
|
||
- 提交 `POST /goods` 带 `mergedOriginGoodIds`(不含主源)。
|
||
- 匹配项默认展开高亮;未匹配兄弟项收入搜索框之后手动勾选。
|
||
|
||
### 6.2 Good 编辑弹窗:关联原产品管理(存量合并入口)
|
||
|
||
- 新增「关联原产品」区:展示主源(标记)+ 副源列表。
|
||
- 操作:添加副源(同分类推荐 + 名称搜索)、移除副源、切换主源(旧主源自动进副源列表)。
|
||
- 存量清理流程:把多余 Good 的原产品添加为保留 Good 的副源 → 删除多余 Good。
|
||
|
||
### 6.3 左栏节点角标
|
||
|
||
合并商品(副源数 > 0)显示 `×N`(N = 主源 + 副源总数)轻量角标。
|
||
|
||
## 7. 已确认决策记录
|
||
|
||
| 决策点 | 结论 |
|
||
|---|---|
|
||
| 存储方案 | 中间表(主源不入表) |
|
||
| 变体重叠(两工厂同色) | 全部拼接不去重,带来源标注 |
|
||
| 主源下架但副源在售 | 商品隐藏(主源为准),换主源可恢复 |
|
||
| 存量重复 Good | 后台手动合并(编辑弹窗 + 删除),不写自动清洗脚本 |
|
||
| 详情字段(详情文本/价格/主图) | 以主源为准 |
|
||
| 列表接口 | 不带变体明细,维持现状 |
|
||
|
||
## 8. 测试策略
|
||
|
||
- API 单测(Jest,遵循 javascript-testing-patterns):
|
||
- create/update:副源写入、含主源 id 报错、去重、不存在 id 报错。
|
||
- getTree:副源引用计入 configuredCount / configuredCountries。
|
||
- public getGood:副源 sdsGoodId 命中、variants 拼接、mediaByColor 同色合并、主源下架仍隐藏。
|
||
- Cascade:删 Good / 删 OriginGood 清理关联行。
|
||
- 前端单测:名称截断匹配纯函数(正常 3/4 段、异常命名、空值)。
|
||
- 回归:现有 goods / origin-goods / public 测试全部通过。
|
||
|
||
## 9. 兼容性说明
|
||
|
||
- `POST /goods` 的 `mergedOriginGoodIds` 可选,现有调用方(含 [data-cleaning/sync-apply.mjs](../../data-cleaning/) 批量脚本)零改动可继续运行;后续脚本可增量传副源。
|
||
- public DTO 只增字段不改既有字段,小程序端向后兼容。
|
||
- 部署顺序:先跑 migration(新表,无破坏性),再发 API,最后发 admin 前端。
|
||
|
||
## 10. 风险与边界
|
||
|
||
- 名称截断规则基于 2026-08-27 生产快照调查(541/552 匹配主格式),规则抽成常量 + 纯函数,便于按新数据调整。
|
||
- 合并组变体数量可能较大(N 个工厂 × 各自 SKU),详情响应体量增大;列表不受影响。
|
||
- 主源唯一决定可见性/价格/详情 → 运营需知晓「换主源」的影响面(编辑弹窗中提示文案说明)。
|
||
|
||
---
|
||
|
||
# 实施计划
|
||
|
||
> 测试运行方式:API 集成测试直连本地库(`apps/api/.env` 的 `DATABASE_URL`,与现有 `goods.service.spec.ts` 相同模式)。前端构建验证 `pnpm --filter @inkreach/admin build`(含 vue-tsc 类型检查)。
|
||
|
||
### Task 1: 数据库 — GoodOriginGood 表
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/prisma/schema.prisma`
|
||
- Create: `apps/api/prisma/migrations/<timestamp>_add_good_origin_goods/migration.sql`(用 `prisma migrate dev --name add_good_origin_goods` 自动生成)
|
||
|
||
- [ ] **Step 1: 修改 schema.prisma**
|
||
|
||
`OriginGood` 模型(约 L36-38)加反向关系:
|
||
|
||
```prisma
|
||
goods Good[]
|
||
detail OriginGoodDetail?
|
||
variants OriginGoodVariant[]
|
||
mergedIntoGoods GoodOriginGood[]
|
||
```
|
||
|
||
`Good` 模型(约 L217)加反向关系:
|
||
|
||
```prisma
|
||
goodTags GoodTag[]
|
||
mergedOriginGoods GoodOriginGood[]
|
||
```
|
||
|
||
在 `GoodTag` 模型之后新增:
|
||
|
||
```prisma
|
||
// ---------- Good ↔ OriginGood (merged secondary sources, M:N) ----------
|
||
// Primary source stays on goods.origin_good_id and is NOT stored here.
|
||
model GoodOriginGood {
|
||
goodId BigInt @map("good_id")
|
||
originGoodId BigInt @map("origin_good_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||
|
||
@@id([goodId, originGoodId])
|
||
@@index([originGoodId])
|
||
@@map("good_origin_goods")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 生成 migration 并更新 client**
|
||
|
||
```bash
|
||
cd /opt/inkreach
|
||
pnpm --filter @inkreach/api exec prisma migrate dev --name add_good_origin_goods
|
||
pnpm --filter @inkreach/api exec prisma generate
|
||
```
|
||
|
||
预期:生成新 migration 目录,`prisma migrate dev` 输出 `Your database is now in sync with your schema`。生成的 SQL 应等价于:
|
||
|
||
```sql
|
||
CREATE TABLE "good_origin_goods" (
|
||
"good_id" BIGINT NOT NULL,
|
||
"origin_good_id" BIGINT NOT NULL,
|
||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY ("good_id", "origin_good_id")
|
||
);
|
||
CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id");
|
||
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey"
|
||
FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey"
|
||
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||
```
|
||
|
||
- [ ] **Step 3: 验证**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api exec prisma validate
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:validate 通过;现有测试全绿(新表为空,不影响存量行为)。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/api/prisma
|
||
git commit -m "feat(db): add good_origin_goods junction table for merged origin goods"
|
||
```
|
||
|
||
### Task 2: goods service — create 支持副源 + DTO 摘要
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/goods/dto/create-good.dto.ts`
|
||
- Modify: `apps/api/src/goods/dto/update-good.dto.ts`
|
||
- Modify: `apps/api/src/goods/dto/batch-create-good.dto.ts`
|
||
- Modify: `apps/api/src/goods/dto/good.dto.ts`
|
||
- Modify: `apps/api/src/goods/goods.service.ts`
|
||
- Test: `apps/api/src/goods/goods.service.spec.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试(追加到 goods.service.spec.ts 的 `afterAll` 前的用例区)**
|
||
|
||
```ts
|
||
describe('merged origin goods', () => {
|
||
it('creates a good with merged origin goods and reads them back', async () => {
|
||
const created = await service.create({
|
||
goodName: `Goods Test ${stamp} merged`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
});
|
||
expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||
[originGoodIds[2].toString(), originGoodIds[3].toString()].sort(),
|
||
);
|
||
const fetched = await service.findOne(BigInt(created.id));
|
||
expect(fetched.mergedOriginGoods.length).toBe(2);
|
||
});
|
||
|
||
it('rejects mergedOriginGoodIds containing the primary', async () => {
|
||
await expect(
|
||
service.create({
|
||
goodName: `Goods Test ${stamp} bad-primary`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[1])],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
}),
|
||
).rejects.toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects mergedOriginGoodIds that do not exist', async () => {
|
||
await expect(
|
||
service.create({
|
||
goodName: `Goods Test ${stamp} bad-missing`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [999999999],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
}),
|
||
).rejects.toThrow(BadRequestException);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行确认失败**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:FAIL(TS 编译报 `mergedOriginGoodIds` 不存在于 DTO)。
|
||
|
||
- [ ] **Step 3: DTO 加字段**
|
||
|
||
`create-good.dto.ts`、`update-good.dto.ts` 各加(放在 `originGoodId` 字段之后):
|
||
|
||
```ts
|
||
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||
@IsOptional()
|
||
@IsArray()
|
||
@IsInt({ each: true })
|
||
@Min(1, { each: true })
|
||
mergedOriginGoodIds?: number[];
|
||
```
|
||
|
||
`batch-create-good.dto.ts` 的 `BatchCreateItemDto` 同样加该字段。
|
||
|
||
- [ ] **Step 4: service 实现**
|
||
|
||
`goods.service.ts`:
|
||
|
||
`GOOD_INCLUDE` 加副源(`goodTags` 行之后):
|
||
|
||
```ts
|
||
goodTags: { include: { tag: true } },
|
||
mergedOriginGoods: {
|
||
include: {
|
||
originGood: {
|
||
include: {
|
||
detail: true,
|
||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||
_count: { select: { variants: true } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
```
|
||
|
||
新增两个私有方法(`ensureOriginGood` 附近):
|
||
|
||
```ts
|
||
/** Dedupe merged ids and reject any that equals the primary source. */
|
||
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
|
||
if (!ids || ids.length === 0) return [];
|
||
const unique = [...new Set(ids.map((id) => BigInt(id)))];
|
||
if (unique.includes(primaryId)) {
|
||
throw new BadRequestException('mergedOriginGoodIds 不能包含主源 originGoodId');
|
||
}
|
||
return unique;
|
||
}
|
||
|
||
private async ensureMergedOriginGoods(ids: bigint[]) {
|
||
if (ids.length === 0) return;
|
||
const rows = await this.prisma.originGood.findMany({
|
||
where: { id: { in: ids } },
|
||
select: { id: true },
|
||
});
|
||
if (rows.length !== ids.length) {
|
||
const found = new Set(rows.map((r) => r.id.toString()));
|
||
const missing = ids.find((id) => !found.has(id.toString()));
|
||
throw new BadRequestException(`Origin good ${missing} not found`);
|
||
}
|
||
}
|
||
```
|
||
|
||
`create()` 中 `ensureReferences` 之后、事务之前:
|
||
|
||
```ts
|
||
const mergedIds = this.dedupeMergedIds(BigInt(dto.originGoodId), dto.mergedOriginGoodIds);
|
||
await this.ensureMergedOriginGoods(mergedIds);
|
||
```
|
||
|
||
事务内 `goodTag.createMany` 之后:
|
||
|
||
```ts
|
||
if (mergedIds.length > 0) {
|
||
await tx.goodOriginGood.createMany({
|
||
data: mergedIds.map((originGoodId) => ({
|
||
goodId: created.id,
|
||
originGoodId,
|
||
})),
|
||
});
|
||
}
|
||
```
|
||
|
||
`batchCreate()`:事务前对每个 item 预校验(`this.dedupeMergedIds(og.id 需先查出)` 改为直接查库取主源记录后校验),事务内 `tx.good.create` 之后同 `create()` 写入副源:
|
||
|
||
```ts
|
||
// 事务内、tx.good.create 之后:
|
||
const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds);
|
||
if (itemMerged.length > 0) {
|
||
const existRows = await tx.originGood.findMany({
|
||
where: { id: { in: itemMerged } },
|
||
select: { id: true },
|
||
});
|
||
if (existRows.length !== itemMerged.length) {
|
||
throw new BadRequestException(
|
||
`Origin good ${item.mergedOriginGoodIds?.find((x) => !existRows.some((r) => r.id === BigInt(x)))} not found`,
|
||
);
|
||
}
|
||
await tx.goodOriginGood.createMany({
|
||
data: itemMerged.map((originGoodId) => ({ goodId: row.id, originGoodId })),
|
||
});
|
||
}
|
||
```
|
||
|
||
`GoodDto`(`good.dto.ts`):`GoodRelations` 接口加:
|
||
|
||
```ts
|
||
mergedOriginGoods?: Array<{
|
||
originGood: {
|
||
id: bigint;
|
||
sdsGoodId: string;
|
||
goodName: string | null;
|
||
goodImage: string | null;
|
||
goodPrice: unknown;
|
||
detail?: { sizeChart: unknown; packageSpecs: unknown; productCode: string | null; syncedAt: Date } | null;
|
||
variants?: Array<{ sdsVariantId: string }>;
|
||
_count?: { variants: number };
|
||
};
|
||
}>;
|
||
```
|
||
|
||
`GoodDto` 类加字段与映射(`originGood` 之后):
|
||
|
||
```ts
|
||
@ApiProperty({ required: false, type: Array })
|
||
mergedOriginGoods!: Array<{
|
||
id: string;
|
||
sdsGoodId: string;
|
||
goodName: string | null;
|
||
goodImage: string | null;
|
||
goodPrice: string | null;
|
||
hasDetail: boolean;
|
||
variantCount: number;
|
||
}>;
|
||
```
|
||
|
||
```ts
|
||
mergedOriginGoods: rel.mergedOriginGoods
|
||
? rel.mergedOriginGoods.map((m) => ({
|
||
id: m.originGood.id.toString(),
|
||
sdsGoodId: m.originGood.sdsGoodId,
|
||
goodName: m.originGood.goodName,
|
||
goodImage: m.originGood.goodImage,
|
||
goodPrice:
|
||
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
|
||
? null
|
||
: (m.originGood.goodPrice as { toString(): string }).toString(),
|
||
hasDetail: Boolean(m.originGood.detail),
|
||
variantCount: m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
|
||
}))
|
||
: [],
|
||
```
|
||
|
||
所有 `GoodDto.from(x, {...})` 调用点(`create`/`findAll`/`update`/`batchCreate` 等)补传 `mergedOriginGoods: x.mergedOriginGoods`。
|
||
|
||
- [ ] **Step 5: 运行测试通过**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:PASS。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/goods
|
||
git commit -m "feat(goods): create goods with merged secondary origin goods"
|
||
```
|
||
|
||
### Task 3: goods service — update 全量覆盖副源
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/goods/goods.service.ts`
|
||
- Test: `apps/api/src/goods/goods.service.spec.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试(追加到 `merged origin goods` describe 内)**
|
||
|
||
```ts
|
||
it('replaces merged origin goods on update', async () => {
|
||
const created = await service.create({
|
||
goodName: `Goods Test ${stamp} replace`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
});
|
||
const updated = await service.update(BigInt(created.id), {
|
||
mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])],
|
||
});
|
||
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||
[originGoodIds[3].toString(), originGoodIds[4].toString()].sort(),
|
||
);
|
||
});
|
||
|
||
it('moves old primary into merged list when switching primary', async () => {
|
||
const created = await service.create({
|
||
goodName: `Goods Test ${stamp} switch`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
});
|
||
const updated = await service.update(BigInt(created.id), {
|
||
originGoodId: Number(originGoodIds[2]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])],
|
||
});
|
||
expect(updated.originGoodId).toBe(originGoodIds[2].toString());
|
||
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||
[originGoodIds[1].toString(), originGoodIds[3].toString()].sort(),
|
||
);
|
||
});
|
||
|
||
it('cascades merged rows on good removal', async () => {
|
||
const created = await service.create({
|
||
goodName: `Goods Test ${stamp} cascade`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
});
|
||
await service.remove(BigInt(created.id));
|
||
const rows = await prisma.goodOriginGood.count({
|
||
where: { goodId: BigInt(created.id) },
|
||
});
|
||
expect(rows).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行确认失败**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:FAIL(update 忽略 mergedOriginGoodIds)。
|
||
|
||
- [ ] **Step 3: 实现 update()**
|
||
|
||
`update()` 中,在 `await this.findOne(id);` 之后加:
|
||
|
||
```ts
|
||
let mergedIds: bigint[] | undefined;
|
||
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
|
||
const current = await this.prisma.good.findUniqueOrThrow({
|
||
where: { id },
|
||
select: { originGoodId: true },
|
||
});
|
||
const primaryId =
|
||
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
|
||
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
|
||
await this.ensureMergedOriginGoods(mergedIds);
|
||
}
|
||
```
|
||
|
||
事务内 `goodTag` 覆盖块之后加:
|
||
|
||
```ts
|
||
if (mergedIds !== undefined) {
|
||
await tx.goodOriginGood.deleteMany({ where: { goodId: id } });
|
||
if (mergedIds.length > 0) {
|
||
await tx.goodOriginGood.createMany({
|
||
data: mergedIds.map((originGoodId) => ({ goodId: id, originGoodId })),
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
事务结果返回后,把详情同步触发扩展为副源也触发:
|
||
|
||
```ts
|
||
const syncTargets = [
|
||
result.originGood,
|
||
...result.mergedOriginGoods.map((m) => m.originGood),
|
||
].filter((og) => og?.source === 'SDS' && og.sdsGoodId && !og.hasDetail);
|
||
```
|
||
|
||
(`create()`/`batchCreate()` 的同步触发同样改用该模式。)
|
||
|
||
- [ ] **Step 4: 运行测试通过**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:PASS。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/goods
|
||
git commit -m "feat(goods): replace merged origin goods on update"
|
||
```
|
||
|
||
### Task 4: goods 详情 — 变体拼接与来源标注
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/goods/dto/good.dto.ts`
|
||
- Test: `apps/api/src/goods/goods.service.spec.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
it('returns merged variants with source annotation in detail', async () => {
|
||
const created = await service.create({
|
||
goodName: `Goods Test ${stamp} variants`,
|
||
originGoodId: Number(originGoodIds[1]),
|
||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||
countryId: Number(countryId),
|
||
categoryId: Number(categoryId),
|
||
});
|
||
const detail = await service.findOne(BigInt(created.id));
|
||
const sources = new Set(detail.variants.map((v) => (v as Record<string, unknown>).originGoodId));
|
||
expect(sources.has(originGoodIds[1].toString())).toBe(true);
|
||
expect(sources.has(originGoodIds[2].toString())).toBe(true);
|
||
expect(detail.originGood.variantCount).toBeGreaterThanOrEqual(
|
||
detail.variants.filter((v) => (v as Record<string, unknown>).originGoodId === originGoodIds[1].toString()).length,
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行确认失败**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:FAIL(variants 无 originGoodId 字段)。
|
||
|
||
- [ ] **Step 3: 实现 GoodDetailDto.fromGood 合并变体**
|
||
|
||
```ts
|
||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||
const base = GoodDto.from(good, rel);
|
||
const detail = rel.originGood?.detail;
|
||
const toVariant = (originGoodId: string, originGoodName: string | null) =>
|
||
(variant: NonNullable<GoodRelations['originGood']>['variants'] extends (infer V)[] | undefined ? V : never) => ({
|
||
...variant,
|
||
price:
|
||
variant.price === null || variant.price === undefined
|
||
? null
|
||
: (variant.price as { toString(): string }).toString(),
|
||
originGoodId,
|
||
originGoodName,
|
||
});
|
||
const mergedVariants = [
|
||
...(rel.originGood?.variants ?? []).map(
|
||
toVariant(good.originGoodId.toString(), rel.originGood?.goodName ?? null),
|
||
),
|
||
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
|
||
(m.originGood.variants ?? []).map(
|
||
toVariant(m.originGood.id.toString(), m.originGood.goodName),
|
||
),
|
||
),
|
||
];
|
||
return {
|
||
...base,
|
||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||
variants: mergedVariants,
|
||
};
|
||
}
|
||
```
|
||
|
||
(若上述条件类型写法编译不过,退化为内联 map 两次,保持行为一致即可。)
|
||
|
||
- [ ] **Step 4: 运行测试通过**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- goods.service.spec.ts
|
||
```
|
||
|
||
预期:PASS。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/goods
|
||
git commit -m "feat(goods): merge primary and secondary variants in detail DTO"
|
||
```
|
||
|
||
### Task 5: origin-goods getTree — 统计合并副源
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/origin-goods/origin-goods.service.ts`
|
||
- Test: `apps/api/src/origin-goods/origin-goods.service.spec.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试(追加到该文件,直接用 prisma 建 fixture,与现有用例同模式)**
|
||
|
||
文件顶部 describe 外补 fixture 变量与树查找 helper:
|
||
|
||
```ts
|
||
let treeCountryId: bigint;
|
||
let treeCategoryId: bigint;
|
||
let treeOriginA: bigint;
|
||
let treeOriginB: bigint;
|
||
|
||
function findOgNode(tree: { tree: any[] }, ogId: string): any {
|
||
let found: any = null;
|
||
function walk(nodes: any[]) {
|
||
for (const n of nodes) {
|
||
if (n.originGoods) {
|
||
const hit = n.originGoods.find((o: any) => o.id === ogId);
|
||
if (hit) { found = hit; return; }
|
||
}
|
||
if (n.children?.length) walk(n.children);
|
||
}
|
||
}
|
||
walk(tree.tree);
|
||
if (!found) throw new Error(`og node ${ogId} not found in tree`);
|
||
return found;
|
||
}
|
||
```
|
||
|
||
新用例:
|
||
|
||
```ts
|
||
it('counts secondary references as configured', async () => {
|
||
const sdsCat = `tree-cat-${stamp}`;
|
||
const country = await prisma.country.create({
|
||
data: { countryName: `Tree Country ${stamp}` },
|
||
});
|
||
treeCountryId = country.id;
|
||
const cat = await prisma.category.create({
|
||
data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat },
|
||
});
|
||
treeCategoryId = cat.id;
|
||
const originA = await prisma.originGood.create({
|
||
data: { sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat },
|
||
});
|
||
const originB = await prisma.originGood.create({
|
||
data: { sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat },
|
||
});
|
||
treeOriginA = originA.id;
|
||
treeOriginB = originB.id;
|
||
|
||
const good = await prisma.good.create({
|
||
data: {
|
||
goodName: `Tree Good ${stamp}`,
|
||
originGoodId: originA.id,
|
||
countryId: treeCountryId,
|
||
categoryId: treeCategoryId,
|
||
},
|
||
});
|
||
await prisma.goodOriginGood.create({
|
||
data: { goodId: good.id, originGoodId: originB.id },
|
||
});
|
||
|
||
const tree = await service.getTree();
|
||
const nodeB = findOgNode(tree, originB.id.toString());
|
||
expect(nodeB.configuredCount).toBe(1);
|
||
expect(nodeB.configuredCountries.length).toBe(1);
|
||
|
||
await prisma.good.delete({ where: { id: good.id } });
|
||
});
|
||
```
|
||
|
||
`afterAll` 追加清理:
|
||
|
||
```ts
|
||
await prisma.category.deleteMany({ where: { id: { in: [treeCategoryId].filter(Boolean) } } });
|
||
await prisma.country.deleteMany({ where: { id: { in: [treeCountryId].filter(Boolean) } } });
|
||
```
|
||
|
||
(`origin_goods` 的 A/B 由现有 `createdSds` 机制外的 `sdsGoodId` 建的,需把 `tree-a-${stamp}`/`tree-b-${stamp}` 也 push 进 `createdSds` 以复用现有清理。)
|
||
|
||
- [ ] **Step 2: 运行确认失败**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts
|
||
```
|
||
|
||
预期:FAIL(B 的 configuredCount 为 0)。
|
||
|
||
- [ ] **Step 3: 实现 getTree() 合并统计**
|
||
|
||
`Promise.all` 查询数组追加两个副源查询:
|
||
|
||
```ts
|
||
this.prisma.goodOriginGood.groupBy({
|
||
by: ['originGoodId'],
|
||
_count: { _all: true },
|
||
}),
|
||
this.prisma.goodOriginGood.findMany({
|
||
select: {
|
||
originGoodId: true,
|
||
good: { select: { country: { select: { countryName: true } } } },
|
||
},
|
||
}),
|
||
```
|
||
|
||
解构对应加 `mergedCounts, mergedWithCountries`。合并逻辑:
|
||
|
||
```ts
|
||
const countMap = new Map<string, number>();
|
||
configCounts.forEach((c) =>
|
||
countMap.set(c.originGoodId.toString(), c._count._all),
|
||
);
|
||
mergedCounts.forEach((c) => {
|
||
const key = c.originGoodId.toString();
|
||
countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
|
||
});
|
||
```
|
||
|
||
`countryMap` 构建循环后追加:
|
||
|
||
```ts
|
||
mergedWithCountries.forEach((m) => {
|
||
const key = m.originGoodId.toString();
|
||
const name = m.good.country?.countryName;
|
||
if (!name) return;
|
||
const arr = countryMap.get(key);
|
||
if (arr) {
|
||
if (!arr.includes(name)) arr.push(name);
|
||
} else {
|
||
countryMap.set(key, [name]);
|
||
}
|
||
});
|
||
```
|
||
|
||
`tagMap`:现有 `goodTag.findMany` 的 `good` select 加 `mergedOriginGoods: { select: { originGoodId: true } }`,遍历时对每个 `gt.good.mergedOriginGoods` 的 `originGoodId` 重复同样的 tagInfo 写入(复用现有去重逻辑,抽个局部 `addTag(key, tagInfo)` helper)。
|
||
|
||
- [ ] **Step 4: 运行测试通过**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts
|
||
```
|
||
|
||
预期:PASS。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/origin-goods
|
||
git commit -m "feat(origin-goods): include merged references in tree stats"
|
||
```
|
||
|
||
### Task 6: public — 副源入口 + 变体/图组合并
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/public/public.service.ts`
|
||
- Test: `apps/api/src/public/public.service.spec.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试(追加到该文件,直接用 prisma 建 fixture,与现有用例同模式)**
|
||
|
||
```ts
|
||
it('resolves a good by secondary sdsGoodId with merged variants', async () => {
|
||
const secondary = await prisma.originGood.create({
|
||
data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` },
|
||
});
|
||
await prisma.originGoodVariant.create({
|
||
data: {
|
||
originGoodId: originGoodId,
|
||
sdsVariantId: `pub-var-pri-${stamp}`,
|
||
sku: `PUB-PRI-${stamp}`,
|
||
colorId: 'black', colorName: '黑色', colorHex: '#000000',
|
||
imageUrl: 'http://img/pri',
|
||
},
|
||
});
|
||
await prisma.originGoodVariant.create({
|
||
data: {
|
||
originGoodId: secondary.id,
|
||
sdsVariantId: `pub-var-sec-${stamp}`,
|
||
sku: `PUB-SEC-${stamp}`,
|
||
colorId: 'black', colorName: '黑色', colorHex: '#000000',
|
||
imageUrl: 'http://img/sec',
|
||
},
|
||
});
|
||
const mergedGood = await prisma.good.create({
|
||
data: {
|
||
goodName: `Pub Merged ${stamp}`,
|
||
originGoodId,
|
||
countryId,
|
||
categoryId,
|
||
},
|
||
});
|
||
await prisma.goodOriginGood.create({
|
||
data: { goodId: mergedGood.id, originGoodId: secondary.id },
|
||
});
|
||
|
||
const detail = await service.getGood(`pub-secondary-${stamp}`);
|
||
expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源 sdsGoodId
|
||
expect(detail.variants.length).toBe(2);
|
||
const black = detail.mediaByColor.find((g) => g.colorName === '黑色');
|
||
expect(black).toBeTruthy();
|
||
expect(black.images.length).toBe(2); // 两源同色图片合到一组
|
||
|
||
await prisma.good.delete({ where: { id: mergedGood.id } });
|
||
await prisma.originGoodVariant.deleteMany({
|
||
where: { sdsVariantId: { in: [`pub-var-pri-${stamp}`, `pub-var-sec-${stamp}`] } },
|
||
});
|
||
await prisma.originGood.delete({ where: { id: secondary.id } });
|
||
});
|
||
```
|
||
|
||
(`pub-sds-${stamp}` / `originGoodId` / `countryId` / `categoryId` 复用该文件 `beforeAll` 已建的 fixture;`pub-sds` 对应的 originGood 可能已有变体 fixture,断言 `variants.length` 时以两源新增变体计数为准,若已有变体则改为 `toBeGreaterThanOrEqual(2)` 并断言包含两个 `sku`。)
|
||
|
||
- [ ] **Step 2: 运行确认失败**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- public.service.spec.ts
|
||
```
|
||
|
||
预期:FAIL(NotFoundException,副源 id 查不到)。
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`PUBLIC_GOOD_INCLUDE` 加:
|
||
|
||
```ts
|
||
mergedOriginGoods: {
|
||
include: {
|
||
originGood: {
|
||
include: {
|
||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
```
|
||
|
||
`getGood()` 的 `where` 改为:
|
||
|
||
```ts
|
||
const good = await this.prisma.good.findFirst({
|
||
where: {
|
||
OR: [
|
||
{ originGood: { sdsGoodId: goodId, delisted: false } },
|
||
{ mergedOriginGoods: { some: { originGood: { sdsGoodId: goodId, delisted: false } } } },
|
||
],
|
||
},
|
||
...
|
||
```
|
||
|
||
`toPublicGoodDetail()` 开头聚合全部变体:
|
||
|
||
```ts
|
||
const allVariants = [
|
||
...good.originGood.variants,
|
||
...good.mergedOriginGoods.flatMap((m) => m.originGood.variants),
|
||
];
|
||
```
|
||
|
||
`mediaByColor: this.groupImagesByColor(allVariants)`,`variants: allVariants.map((variant) => ({ ... }))`(沿用现有字段映射,不加来源字段保持小程序兼容)。
|
||
|
||
- [ ] **Step 4: 运行测试通过**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test -- public.service.spec.ts
|
||
```
|
||
|
||
预期:PASS。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/public
|
||
git commit -m "feat(public): resolve goods by secondary sds id and merge variants"
|
||
```
|
||
|
||
### Task 7: admin — vitest 基建 + 名称匹配纯函数
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin/package.json`(scripts 加 test,devDeps 加 vitest)
|
||
- Create: `apps/admin/src/utils/origin-name.ts`
|
||
- Create: `apps/admin/src/utils/origin-name.spec.ts`
|
||
|
||
- [ ] **Step 1: 安装 vitest**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/admin add -D vitest
|
||
```
|
||
|
||
`apps/admin/package.json` scripts 加:
|
||
|
||
```json
|
||
"test": "vitest run"
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试 `origin-name.spec.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest';
|
||
import { sameOriginGroup, truncateToProcess } from './origin-name';
|
||
|
||
describe('truncateToProcess', () => {
|
||
it('keeps first 3 dash segments (drops warehouse)', () => {
|
||
expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓'))
|
||
.toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花');
|
||
});
|
||
it('is identity for 3-segment names', () => {
|
||
expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'))
|
||
.toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面');
|
||
});
|
||
it('handles malformed names without country prefix', () => {
|
||
expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'))
|
||
.toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花');
|
||
});
|
||
it('returns empty for null/empty', () => {
|
||
expect(truncateToProcess(null)).toBe('');
|
||
expect(truncateToProcess('')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('sameOriginGroup', () => {
|
||
it('matches same product across warehouses', () => {
|
||
expect(sameOriginGroup(
|
||
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓',
|
||
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓',
|
||
)).toBe(true);
|
||
});
|
||
it('does not match different SKU', () => {
|
||
expect(sameOriginGroup(
|
||
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花',
|
||
'美国(包邮)180g纯棉T恤成人款-DG002-单面印花',
|
||
)).toBe(false);
|
||
});
|
||
it('does not match different country', () => {
|
||
expect(sameOriginGroup(
|
||
'美国(包邮)T恤-DG001-单面印花',
|
||
'德国(不包邮)T恤-DG001-单面印花',
|
||
)).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行确认失败**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/admin test
|
||
```
|
||
|
||
预期:FAIL(模块不存在)。
|
||
|
||
- [ ] **Step 4: 实现 `origin-name.ts`**
|
||
|
||
```ts
|
||
/**
|
||
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
|
||
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
|
||
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
|
||
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
|
||
*/
|
||
const KEEP_SEGMENTS = 3;
|
||
|
||
export function truncateToProcess(name: string | null | undefined): string {
|
||
if (!name) return '';
|
||
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
|
||
}
|
||
|
||
export function sameOriginGroup(
|
||
a: string | null | undefined,
|
||
b: string | null | undefined,
|
||
): boolean {
|
||
const ka = truncateToProcess(a);
|
||
return ka !== '' && ka === truncateToProcess(b);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试通过 + 构建不破**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/admin test
|
||
pnpm --filter @inkreach/admin build
|
||
```
|
||
|
||
预期:test PASS;build 通过(spec 文件被 vue-tsc 检查但显式 import vitest 类型可解析;若 build 报 unused/类型错则修 spec 或在 `tsconfig.app.json` 的 include 处理)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/package.json apps/admin/src/utils pnpm-lock.yaml
|
||
git commit -m "feat(admin): add origin name group matching util with vitest"
|
||
```
|
||
|
||
### Task 8: admin — 配置弹窗兄弟多选
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin/src/types/index.ts`
|
||
- Modify: `apps/admin/src/views/goods/GoodsView.vue`
|
||
- 验证:`pnpm --filter @inkreach/admin build`(组件无测试设施,靠类型检查 + 手动验证)
|
||
|
||
- [ ] **Step 1: 类型**
|
||
|
||
`types/index.ts`:
|
||
|
||
```ts
|
||
export interface MergedOriginGoodSummary {
|
||
id: string
|
||
sdsGoodId: string
|
||
goodName: string | null
|
||
goodImage: string | null
|
||
goodPrice: string | null
|
||
hasDetail: boolean
|
||
variantCount: number
|
||
}
|
||
```
|
||
|
||
`Good` 加 `mergedOriginGoods?: MergedOriginGoodSummary[]`;`CreateGoodRequest` 加 `mergedOriginGoodIds?: number[]`;`UpdateGoodRequest` 加 `mergedOriginGoodIds?: number[]`。
|
||
|
||
- [ ] **Step 2: buildRightTree 给 og 节点带 `parentId`**
|
||
|
||
`buildRightTree` 的 `mapCat` 里 og 节点对象加一行:
|
||
|
||
```ts
|
||
parentId: 'rc-' + node.categoryId,
|
||
```
|
||
|
||
- [ ] **Step 3: 配置弹窗状态与逻辑**
|
||
|
||
`GoodsView.vue` script:
|
||
|
||
```ts
|
||
import { sameOriginGroup } from '@/utils/origin-name'
|
||
|
||
const configSiblings = ref<any[]>([]) // 同分类未配置兄弟
|
||
const configChecked = ref<string[]>([]) // 勾选的副源 rawId
|
||
const configPrimaryId = ref<string>('') // 主源 rawId
|
||
|
||
function collectSiblings(ogNode: any): any[] {
|
||
const result: any[] = []
|
||
function traverse(nodes: any[]) {
|
||
for (const n of nodes) {
|
||
if (n.isOG && n.parentId === ogNode.parentId && n.rawId !== ogNode.rawId && !n.configuredCount) result.push(n)
|
||
if (n.children?.length) traverse(n.children)
|
||
}
|
||
}
|
||
traverse(rightTreeData.value)
|
||
return result
|
||
}
|
||
|
||
function openConfigModal(og: any, dropTarget: any) {
|
||
// ...现有逻辑不变,末尾追加:
|
||
const ogNode = findOgNodeById(og.rawId)
|
||
const siblings = ogNode ? collectSiblings(ogNode) : []
|
||
configSiblings.value = siblings
|
||
configPrimaryId.value = String(og.rawId)
|
||
configChecked.value = siblings
|
||
.filter((s) => sameOriginGroup(og.goodName, s.goodName))
|
||
.map((s) => String(s.rawId))
|
||
}
|
||
|
||
function findOgNodeById(rawId: string | number): any {
|
||
let found: any = null
|
||
function traverse(nodes: any[]) {
|
||
for (const n of nodes) {
|
||
if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return }
|
||
if (n.children?.length) traverse(n.children)
|
||
}
|
||
}
|
||
traverse(rightTreeData.value)
|
||
return found
|
||
}
|
||
```
|
||
|
||
`handleConfigSubmit` 的 `goodsApi.createGood` 参数中 `originGoodId` 改用主源 radio 值,并追加副源:
|
||
|
||
```ts
|
||
originGoodId: Number(configPrimaryId.value || configOG.value.rawId),
|
||
mergedOriginGoodIds: configChecked.value
|
||
.filter((id) => id !== configPrimaryId.value)
|
||
.map(Number),
|
||
```
|
||
|
||
主源与副源的关系:主源 = `configPrimaryId`(radio 值),副源 = `configChecked` 中排除主源后的项。checkbox 列表不含当前主源项,radio 切到某个兄弟后它自动从副源提交中排除。
|
||
|
||
- [ ] **Step 4: 配置弹窗模板**
|
||
|
||
`配置原产品` dialog 的 `el-form` 内、「图片」form-item 之前插入:
|
||
|
||
```html
|
||
<el-form-item v-if="configSiblings.length" label="合并同名">
|
||
<div class="config-merge-box">
|
||
<div class="config-merge-tip">勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。</div>
|
||
<div class="config-merge-primary">
|
||
<el-radio-group v-model="configPrimaryId">
|
||
<el-radio :value="String(configOG.rawId)">主源:{{ configOG.goodName }}</el-radio>
|
||
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
|
||
</el-radio-group>
|
||
</div>
|
||
<el-checkbox-group v-model="configChecked">
|
||
<el-checkbox v-for="s in configSiblings" :key="s.rawId" :value="String(s.rawId)">
|
||
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
|
||
</el-checkbox>
|
||
</el-checkbox-group>
|
||
</div>
|
||
</el-form-item>
|
||
```
|
||
|
||
配套 computed:
|
||
|
||
```ts
|
||
const checkedSiblingNodes = computed(() =>
|
||
configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: 构建验证 + 手动验证**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/admin build
|
||
```
|
||
|
||
手动(dev 起后台后):右栏点一个带同名兄弟的商品「配置」→ 弹窗出现勾选列表且同名项已勾 → 提交后左栏 1 条、右栏同组全部已配置。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src
|
||
git commit -m "feat(admin): suggest and merge sibling origin goods in config modal"
|
||
```
|
||
|
||
### Task 9: admin — 编辑弹窗关联原产品管理
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin/src/views/goods/GoodsView.vue`
|
||
|
||
- [ ] **Step 1: 编辑弹窗状态**
|
||
|
||
GoodsView.vue 的 types import 中补 `MergedOriginGoodSummary`(该文件已从 `@/types` import 多个类型,追加到现有 import 列表)。
|
||
|
||
```ts
|
||
const editMerged = ref<MergedOriginGoodSummary[]>([])
|
||
const editMergeSearch = ref('')
|
||
const originalOriginGoodId = ref<string>('') // openEdit 时记录初始主源,提交时判断是否切换
|
||
|
||
// 候选 = 右栏树全部 og(含已配置,用于存量合并),按搜索词过滤
|
||
const editMergeCandidates = computed(() => {
|
||
if (!editMergeSearch.value) return []
|
||
const kw = editMergeSearch.value
|
||
const result: any[] = []
|
||
function traverse(nodes: any[]) {
|
||
for (const n of nodes) {
|
||
if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n)
|
||
if (n.children?.length) traverse(n.children)
|
||
}
|
||
}
|
||
traverse(rightTreeData.value)
|
||
return result.filter(
|
||
(n) => n.rawId !== editGood.value?.originGoodId
|
||
&& !editMerged.value.some((m) => m.id === String(n.rawId)),
|
||
)
|
||
})
|
||
```
|
||
|
||
`openEdit` 里初始化(`editGood.value = g` 之后):
|
||
|
||
```ts
|
||
editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
||
originalOriginGoodId.value = g.originGoodId
|
||
```
|
||
|
||
详情加载完成后(`editGood.value = detail` 之后)覆盖一次:`editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []`。
|
||
|
||
- [ ] **Step 2: 编辑弹窗模板**
|
||
|
||
编辑弹窗「关联原产品」区(`edit-og-ref` div 之后,非 custom 商品时显示):
|
||
|
||
```html
|
||
<div v-if="!editIsCustom" class="edit-merged-box">
|
||
<div class="edit-merged-title">
|
||
关联原产品(主源 + 副源)
|
||
<el-tooltip content="主源决定价格、详情与上下架;副源变体合并展示。切换主源后旧主源自动转为副源。">
|
||
<el-icon><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</div>
|
||
<div class="edit-merged-list">
|
||
<div class="edit-merged-item primary">
|
||
<span class="edit-merged-tag">主</span>
|
||
<span>{{ editGood?.originGood?.goodName }}</span>
|
||
</div>
|
||
<div v-for="m in editMerged" :key="m.id" class="edit-merged-item">
|
||
<span class="edit-merged-tag sub">副</span>
|
||
<span>{{ m.goodName }}</span>
|
||
<el-button size="small" link type="primary" @click="promoteMerged(m)">设为主源</el-button>
|
||
<el-button size="small" link type="danger" @click="editMerged = editMerged.filter(x => x.id !== m.id)">移除</el-button>
|
||
</div>
|
||
</div>
|
||
<el-select v-model="editMergeSearch" filterable remote :remote-method="(q: string) => editMergeSearch = q"
|
||
placeholder="搜索原产品名称以添加副源(用于合并同名商品)" clearable style="width:100%">
|
||
<el-option v-for="c in editMergeCandidates" :key="c.rawId" :label="c.goodName" :value="String(c.rawId)"
|
||
@click="addEditMerge(c)" />
|
||
</el-select>
|
||
</div>
|
||
```
|
||
|
||
配套方法:
|
||
|
||
```ts
|
||
function addEditMerge(node: any) {
|
||
editMerged.value.push({
|
||
id: String(node.rawId), sdsGoodId: node.sdsGoodId,
|
||
goodName: node.goodName, goodImage: node.goodImage,
|
||
goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail),
|
||
variantCount: node.variantCount ?? 0,
|
||
})
|
||
editMergeSearch.value = ''
|
||
}
|
||
|
||
function promoteMerged(m: MergedOriginGoodSummary) {
|
||
if (!editGood.value) return
|
||
const oldPrimaryId = editGood.value.originGoodId
|
||
const oldPrimary = editGood.value.originGood
|
||
editGood.value = {
|
||
...editGood.value,
|
||
originGoodId: m.id,
|
||
originGood: { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName },
|
||
} as any
|
||
editMerged.value = [
|
||
{ id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null,
|
||
goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null,
|
||
hasDetail: oldPrimary?.hasDetail ?? false, variantCount: oldPrimary?.variantCount ?? 0 },
|
||
...editMerged.value.filter((x) => x.id !== m.id),
|
||
]
|
||
}
|
||
```
|
||
|
||
`handleEditSubmit` 的非 custom 分支 payload 追加:
|
||
|
||
```ts
|
||
originGoodId: editGood.value?.originGoodId !== originalOriginGoodId ? Number(editGood.value!.originGoodId) : undefined,
|
||
mergedOriginGoodIds: editMerged.value.map((m) => Number(m.id)),
|
||
```
|
||
|
||
(`originalOriginGoodId` 在 `openEdit` 时记录初始主源 id。)
|
||
|
||
- [ ] **Step 3: 构建验证 + 手动验证**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/admin build
|
||
```
|
||
|
||
手动:编辑弹窗搜索添加副源 → 保存 → 重新打开确认副源在列表;「设为主源」切换后保存 → 详情价格/详情以新主源为准、变体数量增加。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src
|
||
git commit -m "feat(admin): manage merged origin goods in good edit modal"
|
||
```
|
||
|
||
### Task 10: admin — 左栏合并角标
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin/src/views/goods/GoodsView.vue`
|
||
|
||
- [ ] **Step 1: goodToNode 加计数**
|
||
|
||
```ts
|
||
mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0),
|
||
```
|
||
|
||
- [ ] **Step 2: 模板角标**
|
||
|
||
左栏商品节点 label 区域(`goodToNode` 渲染处)加:
|
||
|
||
```html
|
||
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
|
||
```
|
||
|
||
样式(style 区):
|
||
|
||
```scss
|
||
.gv-merged-badge {
|
||
margin-left: 4px;
|
||
padding: 0 5px;
|
||
border-radius: 8px;
|
||
background: var(--el-color-primary-light-8);
|
||
color: var(--el-color-primary);
|
||
font-size: 11px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 构建 + Commit**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/admin build
|
||
git add apps/admin/src
|
||
git commit -m "feat(admin): show merged count badge on good nodes"
|
||
```
|
||
|
||
### Task 11: 全量回归 + 文档更新
|
||
|
||
**Files:**
|
||
- Modify: `docs/references/structs.md`
|
||
- Modify: `docs/references/` 下商品相关文档(如 `product-center.md`)
|
||
- Modify: `README.md`(如含配置流程说明)
|
||
- Modify: `skills/inkreach/SKILL.md`
|
||
|
||
- [ ] **Step 1: 全量测试与构建**
|
||
|
||
```bash
|
||
pnpm --filter @inkreach/api test
|
||
pnpm --filter @inkreach/api build
|
||
pnpm --filter @inkreach/admin test
|
||
pnpm --filter @inkreach/admin build
|
||
```
|
||
|
||
预期:全部通过。任何现有测试失败必须当场修复。
|
||
|
||
- [ ] **Step 2: 更新文档**
|
||
|
||
- `docs/references/structs.md`:新增 `good_origin_goods` 表说明(模块职责一行)。
|
||
- 商品参考文档:`POST /goods` / `PATCH /goods/:id` 的 `mergedOriginGoodIds` 参数说明 + 示例;配置弹窗合并同名操作说明;编辑弹窗关联管理说明;public 详情副源 sdsGoodId 可达说明。
|
||
- `skills/inkreach/SKILL.md`:同步补充接口参数变更。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docs skills README.md
|
||
git commit -m "docs: document merged origin goods feature"
|
||
```
|
||
|
||
- [ ] **Step 4: 按 enterprise-git-spec 合并回 develop**
|
||
|
||
全绿后走企业 Git 规范完成合并。
|
||
|