From 043d2463a65b0ca1d59778a33aa98548f8d11ec2 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 17:08:06 +0800 Subject: [PATCH 01/11] docs(plan): add multi origin good merge design and implementation plan --- .../multi-origin-good-merge-feature.md | 1422 +++++++++++++++++ 1 file changed, 1422 insertions(+) create mode 100644 plans/feature/multi-origin-good-merge-feature.md diff --git a/plans/feature/multi-origin-good-merge-feature.md b/plans/feature/multi-origin-good-merge-feature.md new file mode 100644 index 0000000..3d86b32 --- /dev/null +++ b/plans/feature/multi-origin-good-merge-feature.md @@ -0,0 +1,1422 @@ +# 原产品多对一合并(Multi Origin Good Merge)设计与实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 前置:按 enterprise-git-spec 建分支 `feature/multi-origin-good-merge`。 + +**Goal:** 左栏一个 Good 关联多个原产品(1 主源 + N 副源),变体合并返回,右栏统计正确,小程序副源入口可用,存量数据零迁移。 + +**Architecture:** 新增 `good_origin_goods` 中间表存副源(主源不入表,走现有 `goods.origin_good_id` 外键);admin/public/origin-goods 三个 API 模块在读写处合并"主源 ∪ 副源"两个来源;前端配置弹窗按名称截断规则自动勾选同分类兄弟原产品。 + +**Tech Stack:** NestJS、Prisma(PostgreSQL)、Jest(API 集成测试)、Vue3 + Element Plus、Vitest(admin 纯函数测试)、pnpm + turbo。 + +## 1. 背景与问题 + +上游 SDS 同步的 `origin_goods` 按 `sdsGoodId` 唯一 upsert,存在**多条名称几乎相同、仅尾部(工厂/仓库)不同**的原产品(实测 552 条中,`国家(物流备注)品名-SKU-工艺位置[-仓库名]` 格式里 154 条带第 4 段仓库名,详见 [data-cleaning/README.md](../../data-cleaning/README.md))。 + +当前左右配置功能:右栏(原产品库)点「配置」→ `POST /goods` 创建左栏商品,`goods.origin_good_id` 是**单值非空外键**,一个 Good 只能挂一个原产品。由此产生两个问题: + +1. 同名原产品(不同工厂/颜色/价格)只能各配各的 → 左栏出现多条同名商品,小程序端(`public` 模块)不能给用户看到重复商品。 +2. 只配一条时,右栏另外几条同名原产品仍显示「未配置」,按钮还挂着。 + +## 2. 目标 + +- **左栏一个 Good 可关联多个原产品**:1 主源(现有外键)+ N 副源(新中间表)。 +- **变体合并**:查 Good 时主源 + 副源的 `origin_good_variants` 一次全部带回,全部拼接不去重,靠来源字段区分。 +- **右栏统计正确**:被主源或副源引用过的原产品都显示已配置。 +- **小程序详情入口不失效**:副源的 `sdsGoodId` 也能查到合并后的 Good。 +- **存量数据无损**:现有 1:1 关系零迁移,重复的存量 Good 通过后台手动合并清理。 + +## 3. 方案对比与选择 + +| 方案 | 结论 | 原因 | +|---|---|---| +| A. 中间表 `good_origin_goods` | ✅ 采用 | 真外键 + Cascade 引用完整;Prisma 原生 relation 可 include;统计查询干净(项目已有 `good_tags` 同构先例) | +| B. `goods` 表加 `bigint[]` 数组列 | ❌ | PostgreSQL 数组列不能建外键,会产生悬挂 id;Prisma 只能当裸标量,无关联语义 | +| C. 同步/清洗层直接合并 OriginGood | ❌ | 丢失各工厂独立变体;每次同步要维护合并规则 | +| D. OriginGood 加指向 Good 的外键 | ❌ | 破坏现有「一个原产品配到多个国家/分类」的 1:N 能力 | + +**关键设计:主源不写入中间表。** 主源永远走 `goods.origin_good_id`(原样保留),中间表只存副源。避免同一关系存两处、换主源时要双向同步。 + +## 4. 数据库设计 + +新增表(`goods` 表零改动): + +```prisma +model GoodOriginGood { + goodId BigInt @map("good_id") + originGoodId BigInt @map("origin_good_id") + createdAt DateTime @default(now()) @map("created_at") + + good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction) + originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([goodId, originGoodId]) + @@index([originGoodId]) + @@map("good_origin_goods") +} +``` + +- 复合主键 `(good_id, origin_good_id)`,无自增 id。 +- 双向 `Cascade`:删 Good 或删 OriginGood 自动清理关联行。 +- `Good` / `OriginGood` 模型各加反向 `mergedOriginGoods GoodOriginGood[]` / `mergedIntoGoods GoodOriginGood[]`。 +- 迁移:仅 `CREATE TABLE` + 外键;存量 1:1 数据不需要回填(主源不在表内)。 + +关系示意: + +``` +goods #100 ── origin_good_id(主源,现有外键)──────────→ origin_goods #1 + │ + └── good_origin_goods(副源,新表) + ├── (100, 2) → origin_goods #2 + └── (100, 3) → origin_goods #3 +``` + +## 5. 后端改动(apps/api) + +### 5.1 goods 模块(admin 接口) + +- `CreateGoodDto` / `UpdateGoodDto` / `BatchCreateGoodDto`:新增可选 `mergedOriginGoodIds?: number[]`(副源,不包含主源)。 +- `goods.service.ts`: + - `create()` / `batchCreate()`:事务内校验副源存在且不等于主源、去重后 `createMany` 写入。 + - `update()`:`mergedOriginGoodIds` 全量覆盖(deleteMany + createMany);换主源 `originGoodId` 时若旧主源不在新的副源列表中,由前端保证传入(后端不做隐式迁移)。 + - 详情同步触发:主源 + 副源中所有 `source=SDS && !hasDetail` 的都 `queueProductDetailSync`。 +- `GOOD_INCLUDE` 增加 `mergedOriginGoods: { include: { originGood: { include: { detail: true, variants, _count } } } }`。 +- `GoodDto` 增加 `mergedOriginGoods: Array<{ id, sdsGoodId, goodName, goodPrice, hasDetail, variantCount, ... }>` 摘要数组。 +- `GoodDetailDto.variants`:主源 + 副源变体拼接,每条增加来源标注 `originGoodId` / `originGoodName`;`variantCount` 为合并总数。 + +### 5.2 origin-goods 模块(右栏树) + +`getTree()` 的三个统计口径(`configuredCount` / `configuredCountries` / `configuredTags`)由「仅 goods 表 groupBy」改为「goods 表(主源)∪ good_origin_goods(副源)」两个查询合并。效果:合并组内全部同名原产品显示已配置。 + +### 5.3 public 模块(小程序接口) + +- `getGood(sdsGoodId)`:`where` 改为 `OR: [{ originGood: { sdsGoodId } }, { mergedOriginGoods: { some: { originGood: { sdsGoodId } } } }]`(副源 id 也能命中同一 Good)。 +- `toPublicGoodDetail()`:`variants` 与 `mediaByColor` 合并主源 + 副源全部变体;`groupImagesByColor` 按颜色归组,同色图片自然合并。 +- `price` / `detail` / 排序 / 价格筛选 / 上下架过滤:**仍以主源为准**(已确认决策)。 +- 列表接口(`getGoods` / `getHomeGoods` 等):不返回变体明细,除 `where.originGood.delisted` 语义不变外无改动。 + +## 6. 前端改动(apps/admin,GoodsView.vue) + +### 6.1 配置弹窗增强(新配置流程) + +- 打开弹窗时列出**右栏树同分类(同父节点)下的兄弟原产品**。 +- 自动勾选规则:名称截断到「工艺位置」段后相同的兄弟项。截断规则(来自 [data-cleaning/README.md](../../data-cleaning/README.md) 命名调查): + - 格式:`国家(物流备注)品名-SKU-工艺位置[-仓库名]`,`)`之后按 `-` 分段,**保留前 3 段**(品名-SKU-工艺),丢弃可选的第 4 段仓库名。 + - 匹配 key = 保留的 3 段(或不足 3 段时取全部);截断逻辑抽成独立纯函数 + 单测,规则常量集中定义便于调整。 + - 11 条异常命名(缺国家前缀、半角括号等)匹配不上就不自动勾选,可手动勾。 +- 主源默认 = 点击「配置」的那个,列表中标记「主」;可手动改选主源。 +- 提交 `POST /goods` 带 `mergedOriginGoodIds`(不含主源)。 +- 匹配项默认展开高亮;未匹配兄弟项收入搜索框之后手动勾选。 + +### 6.2 Good 编辑弹窗:关联原产品管理(存量合并入口) + +- 新增「关联原产品」区:展示主源(标记)+ 副源列表。 +- 操作:添加副源(同分类推荐 + 名称搜索)、移除副源、切换主源(旧主源自动进副源列表)。 +- 存量清理流程:把多余 Good 的原产品添加为保留 Good 的副源 → 删除多余 Good。 + +### 6.3 左栏节点角标 + +合并商品(副源数 > 0)显示 `×N`(N = 主源 + 副源总数)轻量角标。 + +## 7. 已确认决策记录 + +| 决策点 | 结论 | +|---|---| +| 存储方案 | 中间表(主源不入表) | +| 变体重叠(两工厂同色) | 全部拼接不去重,带来源标注 | +| 主源下架但副源在售 | 商品隐藏(主源为准),换主源可恢复 | +| 存量重复 Good | 后台手动合并(编辑弹窗 + 删除),不写自动清洗脚本 | +| 详情字段(详情文本/价格/主图) | 以主源为准 | +| 列表接口 | 不带变体明细,维持现状 | + +## 8. 测试策略 + +- API 单测(Jest,遵循 javascript-testing-patterns): + - create/update:副源写入、含主源 id 报错、去重、不存在 id 报错。 + - getTree:副源引用计入 configuredCount / configuredCountries。 + - public getGood:副源 sdsGoodId 命中、variants 拼接、mediaByColor 同色合并、主源下架仍隐藏。 + - Cascade:删 Good / 删 OriginGood 清理关联行。 +- 前端单测:名称截断匹配纯函数(正常 3/4 段、异常命名、空值)。 +- 回归:现有 goods / origin-goods / public 测试全部通过。 + +## 9. 兼容性说明 + +- `POST /goods` 的 `mergedOriginGoodIds` 可选,现有调用方(含 [data-cleaning/sync-apply.mjs](../../data-cleaning/) 批量脚本)零改动可继续运行;后续脚本可增量传副源。 +- public DTO 只增字段不改既有字段,小程序端向后兼容。 +- 部署顺序:先跑 migration(新表,无破坏性),再发 API,最后发 admin 前端。 + +## 10. 风险与边界 + +- 名称截断规则基于 2026-08-27 生产快照调查(541/552 匹配主格式),规则抽成常量 + 纯函数,便于按新数据调整。 +- 合并组变体数量可能较大(N 个工厂 × 各自 SKU),详情响应体量增大;列表不受影响。 +- 主源唯一决定可见性/价格/详情 → 运营需知晓「换主源」的影响面(编辑弹窗中提示文案说明)。 + +--- + +# 实施计划 + +> 测试运行方式:API 集成测试直连本地库(`apps/api/.env` 的 `DATABASE_URL`,与现有 `goods.service.spec.ts` 相同模式)。前端构建验证 `pnpm --filter @inkreach/admin build`(含 vue-tsc 类型检查)。 + +### Task 1: 数据库 — GoodOriginGood 表 + +**Files:** +- Modify: `apps/api/prisma/schema.prisma` +- Create: `apps/api/prisma/migrations/_add_good_origin_goods/migration.sql`(用 `prisma migrate dev --name add_good_origin_goods` 自动生成) + +- [ ] **Step 1: 修改 schema.prisma** + +`OriginGood` 模型(约 L36-38)加反向关系: + +```prisma + goods Good[] + detail OriginGoodDetail? + variants OriginGoodVariant[] + mergedIntoGoods GoodOriginGood[] +``` + +`Good` 模型(约 L217)加反向关系: + +```prisma + goodTags GoodTag[] + mergedOriginGoods GoodOriginGood[] +``` + +在 `GoodTag` 模型之后新增: + +```prisma +// ---------- Good ↔ OriginGood (merged secondary sources, M:N) ---------- +// Primary source stays on goods.origin_good_id and is NOT stored here. +model GoodOriginGood { + goodId BigInt @map("good_id") + originGoodId BigInt @map("origin_good_id") + createdAt DateTime @default(now()) @map("created_at") + + good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction) + originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([goodId, originGoodId]) + @@index([originGoodId]) + @@map("good_origin_goods") +} +``` + +- [ ] **Step 2: 生成 migration 并更新 client** + +```bash +cd /opt/inkreach +pnpm --filter @inkreach/api exec prisma migrate dev --name add_good_origin_goods +pnpm --filter @inkreach/api exec prisma generate +``` + +预期:生成新 migration 目录,`prisma migrate dev` 输出 `Your database is now in sync with your schema`。生成的 SQL 应等价于: + +```sql +CREATE TABLE "good_origin_goods" ( + "good_id" BIGINT NOT NULL, + "origin_good_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("good_id", "origin_good_id") +); +CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id"); +ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" + FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION; +ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" + FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION; +``` + +- [ ] **Step 3: 验证** + +```bash +pnpm --filter @inkreach/api exec prisma validate +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:validate 通过;现有测试全绿(新表为空,不影响存量行为)。 + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/prisma +git commit -m "feat(db): add good_origin_goods junction table for merged origin goods" +``` + +### Task 2: goods service — create 支持副源 + DTO 摘要 + +**Files:** +- Modify: `apps/api/src/goods/dto/create-good.dto.ts` +- Modify: `apps/api/src/goods/dto/update-good.dto.ts` +- Modify: `apps/api/src/goods/dto/batch-create-good.dto.ts` +- Modify: `apps/api/src/goods/dto/good.dto.ts` +- Modify: `apps/api/src/goods/goods.service.ts` +- Test: `apps/api/src/goods/goods.service.spec.ts` + +- [ ] **Step 1: 写失败测试(追加到 goods.service.spec.ts 的 `afterAll` 前的用例区)** + +```ts +describe('merged origin goods', () => { + it('creates a good with merged origin goods and reads them back', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} merged`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual( + [originGoodIds[2].toString(), originGoodIds[3].toString()].sort(), + ); + const fetched = await service.findOne(BigInt(created.id)); + expect(fetched.mergedOriginGoods.length).toBe(2); + }); + + it('rejects mergedOriginGoodIds containing the primary', async () => { + await expect( + service.create({ + goodName: `Goods Test ${stamp} bad-primary`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[1])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects mergedOriginGoodIds that do not exist', async () => { + await expect( + service.create({ + goodName: `Goods Test ${stamp} bad-missing`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [999999999], + countryId: Number(countryId), + categoryId: Number(categoryId), + }), + ).rejects.toThrow(BadRequestException); + }); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:FAIL(TS 编译报 `mergedOriginGoodIds` 不存在于 DTO)。 + +- [ ] **Step 3: DTO 加字段** + +`create-good.dto.ts`、`update-good.dto.ts` 各加(放在 `originGoodId` 字段之后): + +```ts + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; +``` + +`batch-create-good.dto.ts` 的 `BatchCreateItemDto` 同样加该字段。 + +- [ ] **Step 4: service 实现** + +`goods.service.ts`: + +`GOOD_INCLUDE` 加副源(`goodTags` 行之后): + +```ts + goodTags: { include: { tag: true } }, + mergedOriginGoods: { + include: { + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + }, + }, + }, + }, +``` + +新增两个私有方法(`ensureOriginGood` 附近): + +```ts + /** Dedupe merged ids and reject any that equals the primary source. */ + private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] { + if (!ids || ids.length === 0) return []; + const unique = [...new Set(ids.map((id) => BigInt(id)))]; + if (unique.includes(primaryId)) { + throw new BadRequestException('mergedOriginGoodIds 不能包含主源 originGoodId'); + } + return unique; + } + + private async ensureMergedOriginGoods(ids: bigint[]) { + if (ids.length === 0) return; + const rows = await this.prisma.originGood.findMany({ + where: { id: { in: ids } }, + select: { id: true }, + }); + if (rows.length !== ids.length) { + const found = new Set(rows.map((r) => r.id.toString())); + const missing = ids.find((id) => !found.has(id.toString())); + throw new BadRequestException(`Origin good ${missing} not found`); + } + } +``` + +`create()` 中 `ensureReferences` 之后、事务之前: + +```ts + const mergedIds = this.dedupeMergedIds(BigInt(dto.originGoodId), dto.mergedOriginGoodIds); + await this.ensureMergedOriginGoods(mergedIds); +``` + +事务内 `goodTag.createMany` 之后: + +```ts + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ + goodId: created.id, + originGoodId, + })), + }); + } +``` + +`batchCreate()`:事务前对每个 item 预校验(`this.dedupeMergedIds(og.id 需先查出)` 改为直接查库取主源记录后校验),事务内 `tx.good.create` 之后同 `create()` 写入副源: + +```ts + // 事务内、tx.good.create 之后: + const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds); + if (itemMerged.length > 0) { + const existRows = await tx.originGood.findMany({ + where: { id: { in: itemMerged } }, + select: { id: true }, + }); + if (existRows.length !== itemMerged.length) { + throw new BadRequestException( + `Origin good ${item.mergedOriginGoodIds?.find((x) => !existRows.some((r) => r.id === BigInt(x)))} not found`, + ); + } + await tx.goodOriginGood.createMany({ + data: itemMerged.map((originGoodId) => ({ goodId: row.id, originGoodId })), + }); + } +``` + +`GoodDto`(`good.dto.ts`):`GoodRelations` 接口加: + +```ts + mergedOriginGoods?: Array<{ + originGood: { + id: bigint; + sdsGoodId: string; + goodName: string | null; + goodImage: string | null; + goodPrice: unknown; + detail?: { sizeChart: unknown; packageSpecs: unknown; productCode: string | null; syncedAt: Date } | null; + variants?: Array<{ sdsVariantId: string }>; + _count?: { variants: number }; + }; + }>; +``` + +`GoodDto` 类加字段与映射(`originGood` 之后): + +```ts + @ApiProperty({ required: false, type: Array }) + mergedOriginGoods!: Array<{ + id: string; + sdsGoodId: string; + goodName: string | null; + goodImage: string | null; + goodPrice: string | null; + hasDetail: boolean; + variantCount: number; + }>; +``` + +```ts + mergedOriginGoods: rel.mergedOriginGoods + ? rel.mergedOriginGoods.map((m) => ({ + id: m.originGood.id.toString(), + sdsGoodId: m.originGood.sdsGoodId, + goodName: m.originGood.goodName, + goodImage: m.originGood.goodImage, + goodPrice: + m.originGood.goodPrice === null || m.originGood.goodPrice === undefined + ? null + : (m.originGood.goodPrice as { toString(): string }).toString(), + hasDetail: Boolean(m.originGood.detail), + variantCount: m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0, + })) + : [], +``` + +所有 `GoodDto.from(x, {...})` 调用点(`create`/`findAll`/`update`/`batchCreate` 等)补传 `mergedOriginGoods: x.mergedOriginGoods`。 + +- [ ] **Step 5: 运行测试通过** + +```bash +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:PASS。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/goods +git commit -m "feat(goods): create goods with merged secondary origin goods" +``` + +### Task 3: goods service — update 全量覆盖副源 + +**Files:** +- Modify: `apps/api/src/goods/goods.service.ts` +- Test: `apps/api/src/goods/goods.service.spec.ts` + +- [ ] **Step 1: 写失败测试(追加到 `merged origin goods` describe 内)** + +```ts + it('replaces merged origin goods on update', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} replace`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + const updated = await service.update(BigInt(created.id), { + mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])], + }); + expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( + [originGoodIds[3].toString(), originGoodIds[4].toString()].sort(), + ); + }); + + it('moves old primary into merged list when switching primary', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} switch`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + const updated = await service.update(BigInt(created.id), { + originGoodId: Number(originGoodIds[2]), + mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])], + }); + expect(updated.originGoodId).toBe(originGoodIds[2].toString()); + expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( + [originGoodIds[1].toString(), originGoodIds[3].toString()].sort(), + ); + }); + + it('cascades merged rows on good removal', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} cascade`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + await service.remove(BigInt(created.id)); + const rows = await prisma.goodOriginGood.count({ + where: { goodId: BigInt(created.id) }, + }); + expect(rows).toBe(0); + }); +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:FAIL(update 忽略 mergedOriginGoodIds)。 + +- [ ] **Step 3: 实现 update()** + +`update()` 中,在 `await this.findOne(id);` 之后加: + +```ts + let mergedIds: bigint[] | undefined; + if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) { + const current = await this.prisma.good.findUniqueOrThrow({ + where: { id }, + select: { originGoodId: true }, + }); + const primaryId = + dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId; + mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds); + await this.ensureMergedOriginGoods(mergedIds); + } +``` + +事务内 `goodTag` 覆盖块之后加: + +```ts + if (mergedIds !== undefined) { + await tx.goodOriginGood.deleteMany({ where: { goodId: id } }); + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ goodId: id, originGoodId })), + }); + } + } +``` + +事务结果返回后,把详情同步触发扩展为副源也触发: + +```ts + const syncTargets = [ + result.originGood, + ...result.mergedOriginGoods.map((m) => m.originGood), + ].filter((og) => og?.source === 'SDS' && og.sdsGoodId && !og.hasDetail); +``` + +(`create()`/`batchCreate()` 的同步触发同样改用该模式。) + +- [ ] **Step 4: 运行测试通过** + +```bash +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/goods +git commit -m "feat(goods): replace merged origin goods on update" +``` + +### Task 4: goods 详情 — 变体拼接与来源标注 + +**Files:** +- Modify: `apps/api/src/goods/dto/good.dto.ts` +- Test: `apps/api/src/goods/goods.service.spec.ts` + +- [ ] **Step 1: 写失败测试** + +```ts + it('returns merged variants with source annotation in detail', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} variants`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + const detail = await service.findOne(BigInt(created.id)); + const sources = new Set(detail.variants.map((v) => (v as Record).originGoodId)); + expect(sources.has(originGoodIds[1].toString())).toBe(true); + expect(sources.has(originGoodIds[2].toString())).toBe(true); + expect(detail.originGood.variantCount).toBeGreaterThanOrEqual( + detail.variants.filter((v) => (v as Record).originGoodId === originGoodIds[1].toString()).length, + ); + }); +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:FAIL(variants 无 originGoodId 字段)。 + +- [ ] **Step 3: 实现 GoodDetailDto.fromGood 合并变体** + +```ts + static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { + const base = GoodDto.from(good, rel); + const detail = rel.originGood?.detail; + const toVariant = (originGoodId: string, originGoodName: string | null) => + (variant: NonNullable['variants'] extends (infer V)[] | undefined ? V : never) => ({ + ...variant, + price: + variant.price === null || variant.price === undefined + ? null + : (variant.price as { toString(): string }).toString(), + originGoodId, + originGoodName, + }); + const mergedVariants = [ + ...(rel.originGood?.variants ?? []).map( + toVariant(good.originGoodId.toString(), rel.originGood?.goodName ?? null), + ), + ...(rel.mergedOriginGoods ?? []).flatMap((m) => + (m.originGood.variants ?? []).map( + toVariant(m.originGood.id.toString(), m.originGood.goodName), + ), + ), + ]; + return { + ...base, + originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, + variants: mergedVariants, + }; + } +``` + +(若上述条件类型写法编译不过,退化为内联 map 两次,保持行为一致即可。) + +- [ ] **Step 4: 运行测试通过** + +```bash +pnpm --filter @inkreach/api test -- goods.service.spec.ts +``` + +预期:PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/goods +git commit -m "feat(goods): merge primary and secondary variants in detail DTO" +``` + +### Task 5: origin-goods getTree — 统计合并副源 + +**Files:** +- Modify: `apps/api/src/origin-goods/origin-goods.service.ts` +- Test: `apps/api/src/origin-goods/origin-goods.service.spec.ts` + +- [ ] **Step 1: 写失败测试(追加到该文件,直接用 prisma 建 fixture,与现有用例同模式)** + +文件顶部 describe 外补 fixture 变量与树查找 helper: + +```ts + let treeCountryId: bigint; + let treeCategoryId: bigint; + let treeOriginA: bigint; + let treeOriginB: bigint; + + function findOgNode(tree: { tree: any[] }, ogId: string): any { + let found: any = null; + function walk(nodes: any[]) { + for (const n of nodes) { + if (n.originGoods) { + const hit = n.originGoods.find((o: any) => o.id === ogId); + if (hit) { found = hit; return; } + } + if (n.children?.length) walk(n.children); + } + } + walk(tree.tree); + if (!found) throw new Error(`og node ${ogId} not found in tree`); + return found; + } +``` + +新用例: + +```ts + it('counts secondary references as configured', async () => { + const sdsCat = `tree-cat-${stamp}`; + const country = await prisma.country.create({ + data: { countryName: `Tree Country ${stamp}` }, + }); + treeCountryId = country.id; + const cat = await prisma.category.create({ + data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat }, + }); + treeCategoryId = cat.id; + const originA = await prisma.originGood.create({ + data: { sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat }, + }); + const originB = await prisma.originGood.create({ + data: { sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat }, + }); + treeOriginA = originA.id; + treeOriginB = originB.id; + + const good = await prisma.good.create({ + data: { + goodName: `Tree Good ${stamp}`, + originGoodId: originA.id, + countryId: treeCountryId, + categoryId: treeCategoryId, + }, + }); + await prisma.goodOriginGood.create({ + data: { goodId: good.id, originGoodId: originB.id }, + }); + + const tree = await service.getTree(); + const nodeB = findOgNode(tree, originB.id.toString()); + expect(nodeB.configuredCount).toBe(1); + expect(nodeB.configuredCountries.length).toBe(1); + + await prisma.good.delete({ where: { id: good.id } }); + }); +``` + +`afterAll` 追加清理: + +```ts + await prisma.category.deleteMany({ where: { id: { in: [treeCategoryId].filter(Boolean) } } }); + await prisma.country.deleteMany({ where: { id: { in: [treeCountryId].filter(Boolean) } } }); +``` + +(`origin_goods` 的 A/B 由现有 `createdSds` 机制外的 `sdsGoodId` 建的,需把 `tree-a-${stamp}`/`tree-b-${stamp}` 也 push 进 `createdSds` 以复用现有清理。) + +- [ ] **Step 2: 运行确认失败** + +```bash +pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts +``` + +预期:FAIL(B 的 configuredCount 为 0)。 + +- [ ] **Step 3: 实现 getTree() 合并统计** + +`Promise.all` 查询数组追加两个副源查询: + +```ts + this.prisma.goodOriginGood.groupBy({ + by: ['originGoodId'], + _count: { _all: true }, + }), + this.prisma.goodOriginGood.findMany({ + select: { + originGoodId: true, + good: { select: { country: { select: { countryName: true } } } }, + }, + }), +``` + +解构对应加 `mergedCounts, mergedWithCountries`。合并逻辑: + +```ts + const countMap = new Map(); + configCounts.forEach((c) => + countMap.set(c.originGoodId.toString(), c._count._all), + ); + mergedCounts.forEach((c) => { + const key = c.originGoodId.toString(); + countMap.set(key, (countMap.get(key) ?? 0) + c._count._all); + }); +``` + +`countryMap` 构建循环后追加: + +```ts + mergedWithCountries.forEach((m) => { + const key = m.originGoodId.toString(); + const name = m.good.country?.countryName; + if (!name) return; + const arr = countryMap.get(key); + if (arr) { + if (!arr.includes(name)) arr.push(name); + } else { + countryMap.set(key, [name]); + } + }); +``` + +`tagMap`:现有 `goodTag.findMany` 的 `good` select 加 `mergedOriginGoods: { select: { originGoodId: true } }`,遍历时对每个 `gt.good.mergedOriginGoods` 的 `originGoodId` 重复同样的 tagInfo 写入(复用现有去重逻辑,抽个局部 `addTag(key, tagInfo)` helper)。 + +- [ ] **Step 4: 运行测试通过** + +```bash +pnpm --filter @inkreach/api test -- origin-goods.service.spec.ts +``` + +预期:PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/origin-goods +git commit -m "feat(origin-goods): include merged references in tree stats" +``` + +### Task 6: public — 副源入口 + 变体/图组合并 + +**Files:** +- Modify: `apps/api/src/public/public.service.ts` +- Test: `apps/api/src/public/public.service.spec.ts` + +- [ ] **Step 1: 写失败测试(追加到该文件,直接用 prisma 建 fixture,与现有用例同模式)** + +```ts + it('resolves a good by secondary sdsGoodId with merged variants', async () => { + const secondary = await prisma.originGood.create({ + data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId: originGoodId, + sdsVariantId: `pub-var-pri-${stamp}`, + sku: `PUB-PRI-${stamp}`, + colorId: 'black', colorName: '黑色', colorHex: '#000000', + imageUrl: 'http://img/pri', + }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId: secondary.id, + sdsVariantId: `pub-var-sec-${stamp}`, + sku: `PUB-SEC-${stamp}`, + colorId: 'black', colorName: '黑色', colorHex: '#000000', + imageUrl: 'http://img/sec', + }, + }); + const mergedGood = await prisma.good.create({ + data: { + goodName: `Pub Merged ${stamp}`, + originGoodId, + countryId, + categoryId, + }, + }); + await prisma.goodOriginGood.create({ + data: { goodId: mergedGood.id, originGoodId: secondary.id }, + }); + + const detail = await service.getGood(`pub-secondary-${stamp}`); + expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源 sdsGoodId + expect(detail.variants.length).toBe(2); + const black = detail.mediaByColor.find((g) => g.colorName === '黑色'); + expect(black).toBeTruthy(); + expect(black.images.length).toBe(2); // 两源同色图片合到一组 + + await prisma.good.delete({ where: { id: mergedGood.id } }); + await prisma.originGoodVariant.deleteMany({ + where: { sdsVariantId: { in: [`pub-var-pri-${stamp}`, `pub-var-sec-${stamp}`] } }, + }); + await prisma.originGood.delete({ where: { id: secondary.id } }); + }); +``` + +(`pub-sds-${stamp}` / `originGoodId` / `countryId` / `categoryId` 复用该文件 `beforeAll` 已建的 fixture;`pub-sds` 对应的 originGood 可能已有变体 fixture,断言 `variants.length` 时以两源新增变体计数为准,若已有变体则改为 `toBeGreaterThanOrEqual(2)` 并断言包含两个 `sku`。) + +- [ ] **Step 2: 运行确认失败** + +```bash +pnpm --filter @inkreach/api test -- public.service.spec.ts +``` + +预期:FAIL(NotFoundException,副源 id 查不到)。 + +- [ ] **Step 3: 实现** + +`PUBLIC_GOOD_INCLUDE` 加: + +```ts + mergedOriginGoods: { + include: { + originGood: { + include: { + variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + }, + }, + }, + }, +``` + +`getGood()` 的 `where` 改为: + +```ts + const good = await this.prisma.good.findFirst({ + where: { + OR: [ + { originGood: { sdsGoodId: goodId, delisted: false } }, + { mergedOriginGoods: { some: { originGood: { sdsGoodId: goodId, delisted: false } } } }, + ], + }, + ... +``` + +`toPublicGoodDetail()` 开头聚合全部变体: + +```ts + const allVariants = [ + ...good.originGood.variants, + ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants), + ]; +``` + +`mediaByColor: this.groupImagesByColor(allVariants)`,`variants: allVariants.map((variant) => ({ ... }))`(沿用现有字段映射,不加来源字段保持小程序兼容)。 + +- [ ] **Step 4: 运行测试通过** + +```bash +pnpm --filter @inkreach/api test -- public.service.spec.ts +``` + +预期:PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/public +git commit -m "feat(public): resolve goods by secondary sds id and merge variants" +``` + +### Task 7: admin — vitest 基建 + 名称匹配纯函数 + +**Files:** +- Modify: `apps/admin/package.json`(scripts 加 test,devDeps 加 vitest) +- Create: `apps/admin/src/utils/origin-name.ts` +- Create: `apps/admin/src/utils/origin-name.spec.ts` + +- [ ] **Step 1: 安装 vitest** + +```bash +pnpm --filter @inkreach/admin add -D vitest +``` + +`apps/admin/package.json` scripts 加: + +```json + "test": "vitest run" +``` + +- [ ] **Step 2: 写失败测试 `origin-name.spec.ts`** + +```ts +import { describe, expect, it } from 'vitest'; +import { sameOriginGroup, truncateToProcess } from './origin-name'; + +describe('truncateToProcess', () => { + it('keeps first 3 dash segments (drops warehouse)', () => { + expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓')) + .toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花'); + }); + it('is identity for 3-segment names', () => { + expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面')) + .toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'); + }); + it('handles malformed names without country prefix', () => { + expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花')) + .toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'); + }); + it('returns empty for null/empty', () => { + expect(truncateToProcess(null)).toBe(''); + expect(truncateToProcess('')).toBe(''); + }); +}); + +describe('sameOriginGroup', () => { + it('matches same product across warehouses', () => { + expect(sameOriginGroup( + '美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓', + '美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓', + )).toBe(true); + }); + it('does not match different SKU', () => { + expect(sameOriginGroup( + '美国(包邮)180g纯棉T恤成人款-DG001-单面印花', + '美国(包邮)180g纯棉T恤成人款-DG002-单面印花', + )).toBe(false); + }); + it('does not match different country', () => { + expect(sameOriginGroup( + '美国(包邮)T恤-DG001-单面印花', + '德国(不包邮)T恤-DG001-单面印花', + )).toBe(false); + }); +}); +``` + +- [ ] **Step 3: 运行确认失败** + +```bash +pnpm --filter @inkreach/admin test +``` + +预期:FAIL(模块不存在)。 + +- [ ] **Step 4: 实现 `origin-name.ts`** + +```ts +/** + * 原产品名匹配规则(见 data-cleaning/README.md 命名调查): + * `国家(物流备注)品名-SKU-工艺位置[-仓库名]` + * 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。 + * 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。 + */ +const KEEP_SEGMENTS = 3; + +export function truncateToProcess(name: string | null | undefined): string { + if (!name) return ''; + return name.split('-').slice(0, KEEP_SEGMENTS).join('-'); +} + +export function sameOriginGroup( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + const ka = truncateToProcess(a); + return ka !== '' && ka === truncateToProcess(b); +} +``` + +- [ ] **Step 5: 运行测试通过 + 构建不破** + +```bash +pnpm --filter @inkreach/admin test +pnpm --filter @inkreach/admin build +``` + +预期:test PASS;build 通过(spec 文件被 vue-tsc 检查但显式 import vitest 类型可解析;若 build 报 unused/类型错则修 spec 或在 `tsconfig.app.json` 的 include 处理)。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/admin/package.json apps/admin/src/utils pnpm-lock.yaml +git commit -m "feat(admin): add origin name group matching util with vitest" +``` + +### Task 8: admin — 配置弹窗兄弟多选 + +**Files:** +- Modify: `apps/admin/src/types/index.ts` +- Modify: `apps/admin/src/views/goods/GoodsView.vue` +- 验证:`pnpm --filter @inkreach/admin build`(组件无测试设施,靠类型检查 + 手动验证) + +- [ ] **Step 1: 类型** + +`types/index.ts`: + +```ts +export interface MergedOriginGoodSummary { + id: string + sdsGoodId: string + goodName: string | null + goodImage: string | null + goodPrice: string | null + hasDetail: boolean + variantCount: number +} +``` + +`Good` 加 `mergedOriginGoods?: MergedOriginGoodSummary[]`;`CreateGoodRequest` 加 `mergedOriginGoodIds?: number[]`;`UpdateGoodRequest` 加 `mergedOriginGoodIds?: number[]`。 + +- [ ] **Step 2: buildRightTree 给 og 节点带 `parentId`** + +`buildRightTree` 的 `mapCat` 里 og 节点对象加一行: + +```ts + parentId: 'rc-' + node.categoryId, +``` + +- [ ] **Step 3: 配置弹窗状态与逻辑** + +`GoodsView.vue` script: + +```ts +import { sameOriginGroup } from '@/utils/origin-name' + +const configSiblings = ref([]) // 同分类未配置兄弟 +const configChecked = ref([]) // 勾选的副源 rawId +const configPrimaryId = ref('') // 主源 rawId + +function collectSiblings(ogNode: any): any[] { + const result: any[] = [] + function traverse(nodes: any[]) { + for (const n of nodes) { + if (n.isOG && n.parentId === ogNode.parentId && n.rawId !== ogNode.rawId && !n.configuredCount) result.push(n) + if (n.children?.length) traverse(n.children) + } + } + traverse(rightTreeData.value) + return result +} + +function openConfigModal(og: any, dropTarget: any) { + // ...现有逻辑不变,末尾追加: + const ogNode = findOgNodeById(og.rawId) + const siblings = ogNode ? collectSiblings(ogNode) : [] + configSiblings.value = siblings + configPrimaryId.value = String(og.rawId) + configChecked.value = siblings + .filter((s) => sameOriginGroup(og.goodName, s.goodName)) + .map((s) => String(s.rawId)) +} + +function findOgNodeById(rawId: string | number): any { + let found: any = null + function traverse(nodes: any[]) { + for (const n of nodes) { + if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return } + if (n.children?.length) traverse(n.children) + } + } + traverse(rightTreeData.value) + return found +} +``` + +`handleConfigSubmit` 的 `goodsApi.createGood` 参数中 `originGoodId` 改用主源 radio 值,并追加副源: + +```ts + originGoodId: Number(configPrimaryId.value || configOG.value.rawId), + mergedOriginGoodIds: configChecked.value + .filter((id) => id !== configPrimaryId.value) + .map(Number), +``` + +主源与副源的关系:主源 = `configPrimaryId`(radio 值),副源 = `configChecked` 中排除主源后的项。checkbox 列表不含当前主源项,radio 切到某个兄弟后它自动从副源提交中排除。 + +- [ ] **Step 4: 配置弹窗模板** + +`配置原产品` dialog 的 `el-form` 内、「图片」form-item 之前插入: + +```html + +
+
勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。
+
+ + 主源:{{ configOG.goodName }} + {{ s.goodName }} + +
+ + + {{ s.goodName }} + + +
+
+``` + +配套 computed: + +```ts +const checkedSiblingNodes = computed(() => + configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))), +) +``` + +- [ ] **Step 5: 构建验证 + 手动验证** + +```bash +pnpm --filter @inkreach/admin build +``` + +手动(dev 起后台后):右栏点一个带同名兄弟的商品「配置」→ 弹窗出现勾选列表且同名项已勾 → 提交后左栏 1 条、右栏同组全部已配置。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/admin/src +git commit -m "feat(admin): suggest and merge sibling origin goods in config modal" +``` + +### Task 9: admin — 编辑弹窗关联原产品管理 + +**Files:** +- Modify: `apps/admin/src/views/goods/GoodsView.vue` + +- [ ] **Step 1: 编辑弹窗状态** + +GoodsView.vue 的 types import 中补 `MergedOriginGoodSummary`(该文件已从 `@/types` import 多个类型,追加到现有 import 列表)。 + +```ts +const editMerged = ref([]) +const editMergeSearch = ref('') +const originalOriginGoodId = ref('') // openEdit 时记录初始主源,提交时判断是否切换 + +// 候选 = 右栏树全部 og(含已配置,用于存量合并),按搜索词过滤 +const editMergeCandidates = computed(() => { + if (!editMergeSearch.value) return [] + const kw = editMergeSearch.value + const result: any[] = [] + function traverse(nodes: any[]) { + for (const n of nodes) { + if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n) + if (n.children?.length) traverse(n.children) + } + } + traverse(rightTreeData.value) + return result.filter( + (n) => n.rawId !== editGood.value?.originGoodId + && !editMerged.value.some((m) => m.id === String(n.rawId)), + ) +}) +``` + +`openEdit` 里初始化(`editGood.value = g` 之后): + +```ts + editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? [] + originalOriginGoodId.value = g.originGoodId +``` + +详情加载完成后(`editGood.value = detail` 之后)覆盖一次:`editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []`。 + +- [ ] **Step 2: 编辑弹窗模板** + +编辑弹窗「关联原产品」区(`edit-og-ref` div 之后,非 custom 商品时显示): + +```html +
+
+ 关联原产品(主源 + 副源) + + + +
+
+
+ + {{ editGood?.originGood?.goodName }} +
+
+ + {{ m.goodName }} + 设为主源 + 移除 +
+
+ + + +
+``` + +配套方法: + +```ts +function addEditMerge(node: any) { + editMerged.value.push({ + id: String(node.rawId), sdsGoodId: node.sdsGoodId, + goodName: node.goodName, goodImage: node.goodImage, + goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail), + variantCount: node.variantCount ?? 0, + }) + editMergeSearch.value = '' +} + +function promoteMerged(m: MergedOriginGoodSummary) { + if (!editGood.value) return + const oldPrimaryId = editGood.value.originGoodId + const oldPrimary = editGood.value.originGood + editGood.value = { + ...editGood.value, + originGoodId: m.id, + originGood: { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName }, + } as any + editMerged.value = [ + { id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null, + goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null, + hasDetail: oldPrimary?.hasDetail ?? false, variantCount: oldPrimary?.variantCount ?? 0 }, + ...editMerged.value.filter((x) => x.id !== m.id), + ] +} +``` + +`handleEditSubmit` 的非 custom 分支 payload 追加: + +```ts + originGoodId: editGood.value?.originGoodId !== originalOriginGoodId ? Number(editGood.value!.originGoodId) : undefined, + mergedOriginGoodIds: editMerged.value.map((m) => Number(m.id)), +``` + +(`originalOriginGoodId` 在 `openEdit` 时记录初始主源 id。) + +- [ ] **Step 3: 构建验证 + 手动验证** + +```bash +pnpm --filter @inkreach/admin build +``` + +手动:编辑弹窗搜索添加副源 → 保存 → 重新打开确认副源在列表;「设为主源」切换后保存 → 详情价格/详情以新主源为准、变体数量增加。 + +- [ ] **Step 4: Commit** + +```bash +git add apps/admin/src +git commit -m "feat(admin): manage merged origin goods in good edit modal" +``` + +### Task 10: admin — 左栏合并角标 + +**Files:** +- Modify: `apps/admin/src/views/goods/GoodsView.vue` + +- [ ] **Step 1: goodToNode 加计数** + +```ts + mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0), +``` + +- [ ] **Step 2: 模板角标** + +左栏商品节点 label 区域(`goodToNode` 渲染处)加: + +```html +×{{ data.mergedCount }} +``` + +样式(style 区): + +```scss +.gv-merged-badge { + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + background: var(--el-color-primary-light-8); + color: var(--el-color-primary); + font-size: 11px; +} +``` + +- [ ] **Step 3: 构建 + Commit** + +```bash +pnpm --filter @inkreach/admin build +git add apps/admin/src +git commit -m "feat(admin): show merged count badge on good nodes" +``` + +### Task 11: 全量回归 + 文档更新 + +**Files:** +- Modify: `docs/references/structs.md` +- Modify: `docs/references/` 下商品相关文档(如 `product-center.md`) +- Modify: `README.md`(如含配置流程说明) +- Modify: `skills/inkreach/SKILL.md` + +- [ ] **Step 1: 全量测试与构建** + +```bash +pnpm --filter @inkreach/api test +pnpm --filter @inkreach/api build +pnpm --filter @inkreach/admin test +pnpm --filter @inkreach/admin build +``` + +预期:全部通过。任何现有测试失败必须当场修复。 + +- [ ] **Step 2: 更新文档** + +- `docs/references/structs.md`:新增 `good_origin_goods` 表说明(模块职责一行)。 +- 商品参考文档:`POST /goods` / `PATCH /goods/:id` 的 `mergedOriginGoodIds` 参数说明 + 示例;配置弹窗合并同名操作说明;编辑弹窗关联管理说明;public 详情副源 sdsGoodId 可达说明。 +- `skills/inkreach/SKILL.md`:同步补充接口参数变更。 + +- [ ] **Step 3: Commit** + +```bash +git add docs skills README.md +git commit -m "docs: document merged origin goods feature" +``` + +- [ ] **Step 4: 按 enterprise-git-spec 合并回 develop** + +全绿后走企业 Git 规范完成合并。 + From 848eed0b6fe52ba65b79e5ed5ff3e49b01bb901b Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:09:00 +0800 Subject: [PATCH 02/11] feat(db): add good_origin_goods junction table for merged origin goods --- .../migration.sql | 17 +++++++++++++++++ apps/api/prisma/migrations/migration_lock.toml | 4 ++-- apps/api/prisma/schema.prisma | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 apps/api/prisma/migrations/20260827100523_add_good_origin_goods/migration.sql diff --git a/apps/api/prisma/migrations/20260827100523_add_good_origin_goods/migration.sql b/apps/api/prisma/migrations/20260827100523_add_good_origin_goods/migration.sql new file mode 100644 index 0000000..6905354 --- /dev/null +++ b/apps/api/prisma/migrations/20260827100523_add_good_origin_goods/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +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, + + CONSTRAINT "good_origin_goods_pkey" PRIMARY KEY ("good_id","origin_good_id") +); + +-- CreateIndex +CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id"); + +-- AddForeignKey +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; + +-- AddForeignKey +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; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml index 6bf9015..fbffa92 100644 --- a/apps/api/prisma/migrations/migration_lock.toml +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) provider = "postgresql" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 71ed5bd..25b9408 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -36,6 +36,7 @@ model OriginGood { goods Good[] detail OriginGoodDetail? variants OriginGoodVariant[] + mergedIntoGoods GoodOriginGood[] @@index([sdsCategoryId]) @@index([source]) @@ -215,6 +216,7 @@ model Good { tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction) position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction) goodTags GoodTag[] + mergedOriginGoods GoodOriginGood[] @@index([originGoodId]) @@index([countryId]) @@ -241,6 +243,21 @@ model GoodTag { @@map("good_tags") } +// ---------- Good-OriginGood Junction (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") @db.Timestamptz(6) + + 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") +} + // ---------- Users (admin authentication) ---------- enum Role { ADMIN From 9c279ae393587f277774dcdd1cf713272bbd0da7 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:15:56 +0800 Subject: [PATCH 03/11] feat(goods): support merged secondary origin goods in create/update/detail --- .../src/goods/dto/batch-create-good.dto.ts | 7 + apps/api/src/goods/dto/create-good.dto.ts | 7 + apps/api/src/goods/dto/good.dto.ts | 226 +++--- apps/api/src/goods/dto/update-good.dto.ts | 7 + apps/api/src/goods/goods.service.spec.ts | 243 +++++-- apps/api/src/goods/goods.service.ts | 657 ++++++++++-------- 6 files changed, 729 insertions(+), 418 deletions(-) diff --git a/apps/api/src/goods/dto/batch-create-good.dto.ts b/apps/api/src/goods/dto/batch-create-good.dto.ts index 709db62..f31e15c 100644 --- a/apps/api/src/goods/dto/batch-create-good.dto.ts +++ b/apps/api/src/goods/dto/batch-create-good.dto.ts @@ -15,6 +15,13 @@ export class BatchCreateItemDto { @Min(1) originGoodId!: number; + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; + @ApiProperty({ required: false }) @IsOptional() @IsInt() diff --git a/apps/api/src/goods/dto/create-good.dto.ts b/apps/api/src/goods/dto/create-good.dto.ts index 87f8ef3..f766141 100644 --- a/apps/api/src/goods/dto/create-good.dto.ts +++ b/apps/api/src/goods/dto/create-good.dto.ts @@ -20,6 +20,13 @@ export class CreateGoodDto { @Min(1) originGoodId!: number; + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; + @ApiProperty() @IsInt() @Min(1) diff --git a/apps/api/src/goods/dto/good.dto.ts b/apps/api/src/goods/dto/good.dto.ts index 0c543a1..0baf2c9 100644 --- a/apps/api/src/goods/dto/good.dto.ts +++ b/apps/api/src/goods/dto/good.dto.ts @@ -6,33 +6,58 @@ export interface GoodRelations { category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null; tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null; position?: { id: bigint; indexVal: number } | null; - originGood?: { + originGood?: { id: bigint; - sdsGoodId: string; - source: 'SDS' | 'CUSTOM'; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; goodName: string | null; goodImage: string | null; - goodPrice: unknown; - detail?: { - productCode: string | null; - syncedAt: Date; - sizeChart: unknown; - packageSpecs: unknown; - [key: string]: unknown; - } | null; - variants?: Array<{ - sdsVariantId: string; - sku: string; - sizeName: string | null; - colorName: string | null; - colorHex: string | null; - price: unknown; - enabled: boolean; - [key: string]: unknown; - }>; - _count?: { variants: number }; + goodPrice: unknown; + detail?: { + productCode: string | null; + syncedAt: Date; + sizeChart: unknown; + packageSpecs: unknown; + [key: string]: unknown; + } | null; + variants?: Array<{ + sdsVariantId: string; + sku: string; + sizeName: string | null; + colorName: string | null; + colorHex: string | null; + price: unknown; + enabled: boolean; + [key: string]: unknown; + }>; + _count?: { variants: number }; } | null; goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[]; + mergedOriginGoods?: Array<{ + originGood: { + id: bigint; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; + goodName: string | null; + goodImage: string | null; + goodPrice: unknown; + detail?: { syncedAt: Date } | Record | null; + variants?: Array<{ [key: string]: unknown }>; + _count?: { variants: number }; + }; + }>; +} + +export interface MergedOriginGoodSummary { + id: string; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; + isCustom: boolean; + goodName: string | null; + goodImage: string | null; + goodPrice: string | null; + hasDetail: boolean; + variantCount: number; } export class GoodDto { @@ -81,24 +106,27 @@ export class GoodDto { @ApiProperty({ required: false, type: Array }) tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>; + @ApiProperty({ required: false, type: Array }) + mergedOriginGoods!: MergedOriginGoodSummary[]; + @ApiProperty({ required: false, nullable: true }) position?: { id: string; indexVal: number } | null; @ApiProperty({ required: false, nullable: true }) - originGood?: { + originGood?: { id: string; - sdsGoodId: string; - source: 'SDS' | 'CUSTOM'; - isCustom: boolean; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; + isCustom: boolean; goodName: string | null; goodImage: string | null; - goodPrice: string | null; - hasDetail: boolean; - detailSyncedAt: string | null; - variantCount: number; - sizeRowCount: number; - packageRowCount: number; - productCode: string | null; + goodPrice: string | null; + hasDetail: boolean; + detailSyncedAt: string | null; + variantCount: number; + sizeRowCount: number; + packageRowCount: number; + productCode: string | null; } | null; static from( @@ -147,70 +175,108 @@ export class GoodDto { tagFontColor: gt.tag.tagFontColor, })) : [], + mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({ + id: m.originGood.id.toString(), + sdsGoodId: m.originGood.sdsGoodId, + source: m.originGood.source, + isCustom: m.originGood.source === 'CUSTOM', + 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, + })), position: rel.position ? { id: rel.position.id.toString(), indexVal: rel.position.indexVal, } : null, - originGood: rel.originGood - ? { + originGood: rel.originGood + ? { id: rel.originGood.id.toString(), - sdsGoodId: rel.originGood.sdsGoodId, - source: rel.originGood.source, - isCustom: rel.originGood.source === 'CUSTOM', + sdsGoodId: rel.originGood.sdsGoodId, + source: rel.originGood.source, + isCustom: rel.originGood.source === 'CUSTOM', goodName: rel.originGood.goodName, goodImage: rel.originGood.goodImage, goodPrice: rel.originGood.goodPrice === null || rel.originGood.goodPrice === undefined ? null - : (rel.originGood.goodPrice as { toString(): string }).toString(), - hasDetail: Boolean(rel.originGood.detail), - detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null, - variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0, - sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), - packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), - productCode: rel.originGood.detail?.productCode ?? null, - } + : (rel.originGood.goodPrice as { toString(): string }).toString(), + hasDetail: Boolean(rel.originGood.detail), + detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null, + variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0, + sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), + packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), + productCode: rel.originGood.detail?.productCode ?? null, + } : null, - }; - } - - private static jsonRows(value: unknown): number { - if (!value || typeof value !== 'object' || !('rows' in value)) return 0; - const rows = (value as { rows?: unknown }).rows; - return Array.isArray(rows) ? rows.length : 0; - } -} - -export class GoodDetailDto extends GoodDto { - @ApiProperty({ nullable: true, type: Object }) - originDetail!: Record | null; - - @ApiProperty({ type: Array }) - variants!: Array>; - - static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { - const base = GoodDto.from(good, rel); - const detail = rel.originGood?.detail; - return { - ...base, - originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, - variants: (rel.originGood?.variants ?? []).map((variant) => ({ - ...variant, - price: - variant.price === null || variant.price === undefined - ? null - : (variant.price as { toString(): string }).toString(), - })), - }; - } -} + }; + } + + private static jsonRows(value: unknown): number { + if (!value || typeof value !== 'object' || !('rows' in value)) return 0; + const rows = (value as { rows?: unknown }).rows; + return Array.isArray(rows) ? rows.length : 0; + } +} + +export class GoodDetailDto extends GoodDto { + @ApiProperty({ nullable: true, type: Object }) + originDetail!: Record | null; + + @ApiProperty({ type: Array }) + variants!: Array>; + + static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { + const base = GoodDto.from(good, rel); + const detail = rel.originGood?.detail; + const toAnnotated = ( + variant: Record, + originGoodId: string, + originGoodName: string | null, + ) => ({ + ...variant, + price: + variant.price === null || variant.price === undefined + ? null + : (variant.price as unknown as { toString(): string }).toString(), + originGoodId, + originGoodName, + }); + const primaryId = good.originGoodId.toString(); + const primaryName = rel.originGood?.goodName ?? null; + const mergedVariants = [ + ...(rel.originGood?.variants ?? []).map((variant) => + toAnnotated(variant as Record, primaryId, primaryName), + ), + ...(rel.mergedOriginGoods ?? []).flatMap((m) => + (m.originGood.variants ?? []).map((variant) => + toAnnotated( + variant, + m.originGood.id.toString(), + m.originGood.goodName, + ), + ), + ), + ]; + return { + ...base, + originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, + variants: mergedVariants, + }; + } +} export interface PaginatedGoods { items: GoodDto[]; total: number; page: number; pageSize: number; -} +} diff --git a/apps/api/src/goods/dto/update-good.dto.ts b/apps/api/src/goods/dto/update-good.dto.ts index cde1306..54ab7cb 100644 --- a/apps/api/src/goods/dto/update-good.dto.ts +++ b/apps/api/src/goods/dto/update-good.dto.ts @@ -19,6 +19,13 @@ export class UpdateGoodDto { @Min(1) originGoodId?: number; + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; + @ApiProperty({ required: false }) @IsOptional() @IsInt() diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index 2779cdf..b687494 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -4,8 +4,8 @@ import { NotFoundException, } from '@nestjs/common'; import { GoodsService } from './goods.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { SyncService } from '../sync/sync.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SyncService } from '../sync/sync.service'; describe('GoodsService', () => { let service: GoodsService; @@ -23,14 +23,14 @@ describe('GoodsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [ - GoodsService, - PrismaService, - { - provide: SyncService, - useValue: { queueProductDetailSync: jest.fn() }, - }, - ], + providers: [ + GoodsService, + PrismaService, + { + provide: SyncService, + useValue: { queueProductDetailSync: jest.fn() }, + }, + ], }).compile(); service = moduleRef.get(GoodsService); prisma = moduleRef.get(PrismaService); @@ -101,7 +101,7 @@ describe('GoodsService', () => { expect(service).toBeDefined(); }); - it('creates and reads back a good', async () => { + it('creates and reads back a good', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} basic`, originGoodId: Number(originGoodIds[0]), @@ -117,52 +117,52 @@ describe('GoodsService', () => { const fetched = await service.findOne(BigInt(created.id)); expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`); - }); - - it('creates, edits, and removes a fully editable custom good', async () => { - const created = await service.createCustom({ - goodName: `Goods Test ${stamp} custom`, - goodImage: 'https://example.com/custom.png', - goodPrice: '29.90', - countryId: Number(countryId), - categoryId: Number(categoryId), - tagIds: [Number(tagId)], - detail: { - productCode: `CUSTOM-${stamp}`, - materialDescription: 'Cotton', - sizeChart: { columns: [], rows: [] }, - packageSpecs: { rows: [] }, - }, - variants: [ - { sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' }, - ], - }); - - expect(created.originGood?.source).toBe('CUSTOM'); - expect(created.originGood?.isCustom).toBe(true); - expect(created.originGood?.goodPrice).toBe('29.9'); - expect(created.variants).toHaveLength(1); - - const updated = await service.updateCustomContent(BigInt(created.id), { - goodName: `Goods Test ${stamp} custom edited`, - goodPrice: '39.90', - detail: { materialDescription: 'Organic cotton' }, - variants: [ - { sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' }, - ], - }); - expect(updated.goodName).toContain('custom edited'); - expect(updated.originGood?.goodPrice).toBe('39.9'); - expect(updated.originDetail?.materialDescription).toBe('Organic cotton'); - expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`); - expect(updated.variants[0]?.sizeName).toBe('M'); - - const customOriginId = BigInt(updated.originGoodId); - await service.remove(BigInt(updated.id)); - await expect( - prisma.originGood.findUnique({ where: { id: customOriginId } }), - ).resolves.toBeNull(); - }); + }); + + it('creates, edits, and removes a fully editable custom good', async () => { + const created = await service.createCustom({ + goodName: `Goods Test ${stamp} custom`, + goodImage: 'https://example.com/custom.png', + goodPrice: '29.90', + countryId: Number(countryId), + categoryId: Number(categoryId), + tagIds: [Number(tagId)], + detail: { + productCode: `CUSTOM-${stamp}`, + materialDescription: 'Cotton', + sizeChart: { columns: [], rows: [] }, + packageSpecs: { rows: [] }, + }, + variants: [ + { sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' }, + ], + }); + + expect(created.originGood?.source).toBe('CUSTOM'); + expect(created.originGood?.isCustom).toBe(true); + expect(created.originGood?.goodPrice).toBe('29.9'); + expect(created.variants).toHaveLength(1); + + const updated = await service.updateCustomContent(BigInt(created.id), { + goodName: `Goods Test ${stamp} custom edited`, + goodPrice: '39.90', + detail: { materialDescription: 'Organic cotton' }, + variants: [ + { sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' }, + ], + }); + expect(updated.goodName).toContain('custom edited'); + expect(updated.originGood?.goodPrice).toBe('39.9'); + expect(updated.originDetail?.materialDescription).toBe('Organic cotton'); + expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`); + expect(updated.variants[0]?.sizeName).toBe('M'); + + const customOriginId = BigInt(updated.originGoodId); + await service.remove(BigInt(updated.id)); + await expect( + prisma.originGood.findUnique({ where: { id: customOriginId } }), + ).resolves.toBeNull(); + }); it('filters by countryId, tagId, positionId and keyword', async () => { const result = await service.findAll({ @@ -261,6 +261,135 @@ describe('GoodsService', () => { expect(after.total).toBe(before.total); }); + 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); + }); + + 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); + }); + + it('returns merged variants with source annotation in detail', async () => { + const v1 = await prisma.originGoodVariant.create({ + data: { + originGoodId: originGoodIds[1], + sdsVariantId: `mv-pri-${stamp}`, + sku: `MV-PRI-${stamp}`, + colorName: '黑色', + }, + }); + const v2 = await prisma.originGoodVariant.create({ + data: { + originGoodId: originGoodIds[2], + sdsVariantId: `mv-sec-${stamp}`, + sku: `MV-SEC-${stamp}`, + colorName: '白色', + }, + }); + try { + 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['originGoodId'] as string), + ); + expect(sources.has(originGoodIds[1].toString())).toBe(true); + expect(sources.has(originGoodIds[2].toString())).toBe(true); + expect(detail.variants).toHaveLength(2); + expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1); + } finally { + await prisma.originGoodVariant.delete({ where: { id: v1.id } }); + await prisma.originGoodVariant.delete({ where: { id: v2.id } }); + } + }); + }); + it('throws NotFoundException for unknown id', async () => { await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 3d17234..0d094fa 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -9,38 +9,50 @@ import { CreateGoodDto } from './dto/create-good.dto'; import { UpdateGoodDto } from './dto/update-good.dto'; import { QueryGoodDto } from './dto/query-good.dto'; import { BatchCreateGoodDto } from './dto/batch-create-good.dto'; -import { BatchPriorityDto } from './dto/batch-priority.dto'; -import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; -import { SyncService } from '../sync/sync.service'; -import { randomUUID } from 'crypto'; -import { - CreateCustomGoodDto, - CustomGoodDetailDto, - CustomGoodVariantDto, - UpdateCustomGoodContentDto, -} from './dto/custom-good.dto'; +import { BatchPriorityDto } from './dto/batch-priority.dto'; +import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; +import { SyncService } from '../sync/sync.service'; +import { randomUUID } from 'crypto'; +import { + CreateCustomGoodDto, + CustomGoodDetailDto, + CustomGoodVariantDto, + UpdateCustomGoodContentDto, +} from './dto/custom-good.dto'; const GOOD_INCLUDE = { country: true, category: true, tag: true, position: true, - originGood: { - include: { - detail: true, - variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, - _count: { select: { variants: true } }, - }, - }, + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + }, + }, goodTags: { include: { tag: true } }, + mergedOriginGoods: { + orderBy: { createdAt: 'asc' }, + include: { + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + }, + }, + }, + }, } satisfies Prisma.GoodInclude; @Injectable() export class GoodsService { - constructor( - private readonly prisma: PrismaService, - private readonly syncService: SyncService, - ) {} + constructor( + private readonly prisma: PrismaService, + private readonly syncService: SyncService, + ) {} async findAll(query: QueryGoodDto): Promise { const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query; @@ -75,6 +87,7 @@ export class GoodsService { position: g.position, originGood: g.originGood, goodTags: g.goodTags, + mergedOriginGoods: g.mergedOriginGoods, })), total, page, @@ -82,25 +95,31 @@ export class GoodsService { }; } - async findOne(id: bigint): Promise { + async findOne(id: bigint): Promise { const good = await this.prisma.good.findUnique({ where: { id }, include: GOOD_INCLUDE, }); if (!good) throw new NotFoundException(`Good ${id} not found`); - return GoodDetailDto.fromGood(good, { + return GoodDetailDto.fromGood(good, { country: good.country, category: good.category, tag: good.tag, position: good.position, originGood: good.originGood, goodTags: good.goodTags, + mergedOriginGoods: good.mergedOriginGoods, }); } - async create(dto: CreateGoodDto): Promise { - await this.ensureReferences(dto); - const result = await this.prisma.$transaction(async (tx) => { + async create(dto: CreateGoodDto): Promise { + await this.ensureReferences(dto); + const mergedIds = this.dedupeMergedIds( + BigInt(dto.originGoodId), + dto.mergedOriginGoodIds, + ); + await this.ensureMergedOriginGoods(mergedIds); + const result = await this.prisma.$transaction(async (tx) => { const created = await tx.good.create({ data: { goodName: dto.goodName, @@ -120,132 +139,152 @@ export class GoodsService { })), }); } + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ + goodId: created.id, + originGoodId, + })), + }); + } const result = await tx.good.findUniqueOrThrow({ where: { id: created.id }, include: GOOD_INCLUDE, }); - return GoodDto.from(result, { + return GoodDto.from(result, { country: result.country, category: result.category, tag: result.tag, position: result.position, originGood: result.originGood, goodTags: result.goodTags, - }); - }); - if ( - result.originGood?.source === 'SDS' && - result.originGood.sdsGoodId && - !result.originGood.hasDetail - ) { - this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); - } - return result; - } - - async createCustom(dto: CreateCustomGoodDto): Promise { - await this.ensureCountry(dto.countryId); - await this.ensureCategory(dto.categoryId); - if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); - for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); - - const goodId = await this.prisma.$transaction(async (tx) => { - const originGood = await tx.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: `custom-${randomUUID()}`, - goodName: dto.goodName, - goodImage: dto.goodImage ?? null, - goodPrice: this.decimal(dto.goodPrice), - detail: { - create: this.customDetailData( - dto.detail ?? {}, - ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput, - }, - }, - }); - if (dto.variants?.length) { - await this.replaceCustomVariants(tx, originGood.id, dto.variants); - } - const good = await tx.good.create({ - data: { - originGoodId: originGood.id, - countryId: BigInt(dto.countryId), - categoryId: BigInt(dto.categoryId), - positionId: - dto.positionId === undefined ? null : BigInt(dto.positionId), - goodName: dto.goodName, - goodImage: dto.goodImage ?? null, - goodPriority: dto.goodPriority ?? 0, - }, - }); - if (dto.tagIds?.length) { - await tx.goodTag.createMany({ - data: dto.tagIds.map((tagId) => ({ - goodId: good.id, - tagId: BigInt(tagId), - })), - }); - } - return good.id; - }); - return this.findOne(goodId); - } - - async updateCustomContent( - id: bigint, - dto: UpdateCustomGoodContentDto, - ): Promise { - const existing = await this.prisma.good.findUnique({ - where: { id }, - include: { originGood: true }, - }); - if (!existing) throw new NotFoundException(`Good ${id} not found`); - if (existing.originGood.source !== 'CUSTOM') { - throw new BadRequestException('SDS 映射商品的上游信息不可修改'); - } - - await this.prisma.$transaction(async (tx) => { - await tx.originGood.update({ - where: { id: existing.originGoodId }, - data: { - goodName: dto.goodName, - goodImage: dto.goodImage, - goodPrice: - dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice), - }, - }); - if (dto.detail !== undefined) { - await tx.originGoodDetail.upsert({ - where: { originGoodId: existing.originGoodId }, - create: { - originGoodId: existing.originGoodId, - ...(this.customDetailData( - dto.detail, - ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput), - }, - update: this.customDetailData(dto.detail, true), - }); - } - if (dto.variants !== undefined) { - await this.replaceCustomVariants( - tx, - existing.originGoodId, - dto.variants, - ); - } - const goodData: Prisma.GoodUpdateInput = {}; - if (dto.goodName !== undefined) goodData.goodName = dto.goodName; - if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; - if (Object.keys(goodData).length) { - await tx.good.update({ where: { id }, data: goodData }); - } - }); - return this.findOne(id); - } + mergedOriginGoods: result.mergedOriginGoods, + }); + }); + if ( + result.originGood?.source === 'SDS' && + result.originGood.sdsGoodId && + !result.originGood.hasDetail + ) { + this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); + } + return result; + } + + async createCustom(dto: CreateCustomGoodDto): Promise { + await this.ensureCountry(dto.countryId); + await this.ensureCategory(dto.categoryId); + if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); + for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); + + const goodId = await this.prisma.$transaction(async (tx) => { + const originGood = await tx.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: `custom-${randomUUID()}`, + goodName: dto.goodName, + goodImage: dto.goodImage ?? null, + goodPrice: this.decimal(dto.goodPrice), + detail: { + create: this.customDetailData( + dto.detail ?? {}, + ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput, + }, + }, + }); + if (dto.variants?.length) { + await this.replaceCustomVariants(tx, originGood.id, dto.variants); + } + const good = await tx.good.create({ + data: { + originGoodId: originGood.id, + countryId: BigInt(dto.countryId), + categoryId: BigInt(dto.categoryId), + positionId: + dto.positionId === undefined ? null : BigInt(dto.positionId), + goodName: dto.goodName, + goodImage: dto.goodImage ?? null, + goodPriority: dto.goodPriority ?? 0, + }, + }); + if (dto.tagIds?.length) { + await tx.goodTag.createMany({ + data: dto.tagIds.map((tagId) => ({ + goodId: good.id, + tagId: BigInt(tagId), + })), + }); + } + return good.id; + }); + return this.findOne(goodId); + } + + async updateCustomContent( + id: bigint, + dto: UpdateCustomGoodContentDto, + ): Promise { + const existing = await this.prisma.good.findUnique({ + where: { id }, + include: { originGood: true }, + }); + if (!existing) throw new NotFoundException(`Good ${id} not found`); + if (existing.originGood.source !== 'CUSTOM') { + throw new BadRequestException('SDS 映射商品的上游信息不可修改'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.originGood.update({ + where: { id: existing.originGoodId }, + data: { + goodName: dto.goodName, + goodImage: dto.goodImage, + goodPrice: + dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice), + }, + }); + if (dto.detail !== undefined) { + await tx.originGoodDetail.upsert({ + where: { originGoodId: existing.originGoodId }, + create: { + originGoodId: existing.originGoodId, + ...(this.customDetailData( + dto.detail, + ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput), + }, + update: this.customDetailData(dto.detail, true), + }); + } + if (dto.variants !== undefined) { + await this.replaceCustomVariants( + tx, + existing.originGoodId, + dto.variants, + ); + } + const goodData: Prisma.GoodUpdateInput = {}; + if (dto.goodName !== undefined) goodData.goodName = dto.goodName; + if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; + if (Object.keys(goodData).length) { + await tx.good.update({ where: { id }, data: goodData }); + } + }); + return this.findOne(id); + } async update(id: bigint, dto: UpdateGoodDto): Promise { 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); + } const data: Prisma.GoodUpdateInput = {}; if (dto.goodName !== undefined) data.goodName = dto.goodName; if (dto.originGoodId !== undefined) { @@ -274,7 +313,7 @@ export class GoodsService { } } - const result = await this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { if (dto.tagIds !== undefined) { await tx.goodTag.deleteMany({ where: { goodId: id } }); if (dto.tagIds.length > 0) { @@ -286,47 +325,59 @@ export class GoodsService { }); } } + 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 updated = await tx.good.update({ where: { id }, data, include: GOOD_INCLUDE, }); - return GoodDto.from(updated, { + return GoodDto.from(updated, { country: updated.country, category: updated.category, tag: updated.tag, position: updated.position, originGood: updated.originGood, goodTags: updated.goodTags, - }); - }); - if ( - result.originGood?.source === 'SDS' && - result.originGood.sdsGoodId && - !result.originGood.hasDetail - ) { - this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); - } - return result; + mergedOriginGoods: updated.mergedOriginGoods, + }); + }); + if ( + result.originGood?.source === 'SDS' && + result.originGood.sdsGoodId && + !result.originGood.hasDetail + ) { + this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); + } + return result; } - async remove(id: bigint): Promise<{ id: string }> { - const good = await this.prisma.good.findUnique({ - where: { id }, - include: { originGood: true }, - }); - if (!good) throw new NotFoundException(`Good ${id} not found`); - await this.prisma.$transaction(async (tx) => { - await tx.good.delete({ where: { id } }); - if (good.originGood.source === 'CUSTOM') { - const remaining = await tx.good.count({ - where: { originGoodId: good.originGoodId }, - }); - if (remaining === 0) { - await tx.originGood.delete({ where: { id: good.originGoodId } }); - } - } - }); + async remove(id: bigint): Promise<{ id: string }> { + const good = await this.prisma.good.findUnique({ + where: { id }, + include: { originGood: true }, + }); + if (!good) throw new NotFoundException(`Good ${id} not found`); + await this.prisma.$transaction(async (tx) => { + await tx.good.delete({ where: { id } }); + if (good.originGood.source === 'CUSTOM') { + const remaining = await tx.good.count({ + where: { originGoodId: good.originGoodId }, + }); + if (remaining === 0) { + await tx.originGood.delete({ where: { id: good.originGoodId } }); + } + } + }); return { id: id.toString() }; } @@ -335,16 +386,16 @@ export class GoodsService { * or none do. */ async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> { - const result = await this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { for (const item of dto.items) { await tx.good.update({ where: { id: BigInt(item.id) }, data: { goodPriority: item.priority }, }); } - return { count: dto.items.length }; - }); - return result; + return { count: dto.items.length }; + }); + return result; } /** @@ -358,7 +409,7 @@ export class GoodsService { await this.ensureTag(tagId); } } - const result = await this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { const created: GoodDto[] = []; for (const item of dto.items) { const og = await tx.originGood.findUnique({ @@ -388,6 +439,24 @@ export class GoodsService { })), }); } + 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) { + const found = new Set(existRows.map((r) => r.id.toString())); + const missing = itemMerged.find((mid) => !found.has(mid.toString())); + throw new BadRequestException(`Origin good ${missing} not found`); + } + await tx.goodOriginGood.createMany({ + data: itemMerged.map((originGoodId) => ({ + goodId: row.id, + originGoodId, + })), + }); + } const result = await tx.good.findUniqueOrThrow({ where: { id: row.id }, include: GOOD_INCLUDE, @@ -399,23 +468,24 @@ export class GoodsService { position: result.position, originGood: result.originGood, goodTags: result.goodTags, + mergedOriginGoods: result.mergedOriginGoods, })); } - return created; - }); - for (const goodId of new Set( - result - .filter( - (item) => - item.originGood?.source === 'SDS' && - !item.originGood.hasDetail, - ) - .map((item) => item.originGood?.sdsGoodId) - .filter((id): id is string => Boolean(id)), - )) { - this.syncService.queueProductDetailSync(goodId); - } - return result; + return created; + }); + for (const goodId of new Set( + result + .filter( + (item) => + item.originGood?.source === 'SDS' && + !item.originGood.hasDetail, + ) + .map((item) => item.originGood?.sdsGoodId) + .filter((id): id is string => Boolean(id)), + )) { + this.syncService.queueProductDetailSync(goodId); + } + return result; } /** @@ -446,6 +516,31 @@ export class GoodsService { if (!og) throw new BadRequestException(`Origin good ${id} not found`); } + /** 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`); + } + } + private async ensureCountry(id: number) { const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } }); if (!c) throw new BadRequestException(`Country ${id} not found`); @@ -456,12 +551,12 @@ export class GoodsService { if (!c) throw new BadRequestException(`Category ${id} not found`); } - private async ensureTag(id: number) { + private async ensureTag(id: number) { const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } }); if (!t) throw new BadRequestException(`Tag ${id} not found`); } - private async ensureReferences(dto: CreateGoodDto) { + private async ensureReferences(dto: CreateGoodDto) { await this.ensureOriginGood(dto.originGoodId); await this.ensureCountry(dto.countryId); await this.ensureCategory(dto.categoryId); @@ -474,94 +569,94 @@ export class GoodsService { const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } }); if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`); } - } - - private async ensurePosition(id: number) { - const position = await this.prisma.position.findUnique({ - where: { id: BigInt(id) }, - }); - if (!position) throw new BadRequestException(`Position ${id} not found`); - } - - private decimal(value: string | null | undefined): Prisma.Decimal | null { - return value === undefined || value === null || value === '' - ? null - : new Prisma.Decimal(value); - } - - private customDetailData( - detail: CustomGoodDetailDto, - preserveMissing = false, - ): Prisma.OriginGoodDetailUncheckedUpdateInput { - const nullable = (value: T | null | undefined): T | null | undefined => - preserveMissing && value === undefined ? undefined : value ?? null; - const decimal = (value: string | null | undefined) => - preserveMissing && value === undefined ? undefined : this.decimal(value); - const json = ( - value: Record | null | undefined, - ): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined => - preserveMissing && value === undefined - ? undefined - : value === null || value === undefined - ? Prisma.DbNull - : (value as Prisma.InputJsonValue); - return { - productCode: nullable(detail.productCode), - englishName: nullable(detail.englishName), - blankDesignUrl: nullable(detail.blankDesignUrl), - detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl), - textureName: nullable(detail.textureName), - productionCycleHours: nullable(detail.productionCycleHours), - minWeightG: decimal(detail.minWeightG), - reminder: nullable(detail.reminder), - productionProcess: nullable(detail.productionProcess), - materialDescription: nullable(detail.materialDescription), - productPerformance: nullable(detail.productPerformance), - applicableScenarios: nullable(detail.applicableScenarios), - washingInstructions: nullable(detail.washingInstructions), - specialDescription: nullable(detail.specialDescription), - designExplanation: nullable(detail.designExplanation), - designArea: nullable(detail.designArea), - pictureRequest: nullable(detail.pictureRequest), - sizeChart: json(detail.sizeChart), - packageSpecs: json(detail.packageSpecs), - options: json(detail.options), - media: json(detail.media), - }; - } - - private async replaceCustomVariants( - tx: Prisma.TransactionClient, - originGoodId: bigint, - variants: CustomGoodVariantDto[], - ): Promise { - await tx.originGoodVariant.deleteMany({ where: { originGoodId } }); - for (const variant of variants) { - await tx.originGoodVariant.create({ - data: { - originGoodId, - sdsVariantId: `custom-${randomUUID()}`, - sku: variant.sku, - sizeId: variant.sizeId ?? null, - sizeName: variant.sizeName ?? null, - colorId: variant.colorId ?? null, - colorName: variant.colorName ?? null, - colorHex: variant.colorHex ?? null, - imageUrl: variant.imageUrl ?? null, - price: this.decimal(variant.price), - originalPrice: this.decimal(variant.originalPrice), - weightG: this.decimal(variant.weightG), - boxLengthCm: this.decimal(variant.boxLengthCm), - boxWidthCm: this.decimal(variant.boxWidthCm), - boxHeightCm: this.decimal(variant.boxHeightCm), - enabled: variant.enabled ?? true, - sortOrder: variant.sortOrder ?? 0, - designData: - variant.designData === null || variant.designData === undefined - ? Prisma.DbNull - : (variant.designData as Prisma.InputJsonValue), - }, - }); - } - } -} + } + + private async ensurePosition(id: number) { + const position = await this.prisma.position.findUnique({ + where: { id: BigInt(id) }, + }); + if (!position) throw new BadRequestException(`Position ${id} not found`); + } + + private decimal(value: string | null | undefined): Prisma.Decimal | null { + return value === undefined || value === null || value === '' + ? null + : new Prisma.Decimal(value); + } + + private customDetailData( + detail: CustomGoodDetailDto, + preserveMissing = false, + ): Prisma.OriginGoodDetailUncheckedUpdateInput { + const nullable = (value: T | null | undefined): T | null | undefined => + preserveMissing && value === undefined ? undefined : value ?? null; + const decimal = (value: string | null | undefined) => + preserveMissing && value === undefined ? undefined : this.decimal(value); + const json = ( + value: Record | null | undefined, + ): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined => + preserveMissing && value === undefined + ? undefined + : value === null || value === undefined + ? Prisma.DbNull + : (value as Prisma.InputJsonValue); + return { + productCode: nullable(detail.productCode), + englishName: nullable(detail.englishName), + blankDesignUrl: nullable(detail.blankDesignUrl), + detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl), + textureName: nullable(detail.textureName), + productionCycleHours: nullable(detail.productionCycleHours), + minWeightG: decimal(detail.minWeightG), + reminder: nullable(detail.reminder), + productionProcess: nullable(detail.productionProcess), + materialDescription: nullable(detail.materialDescription), + productPerformance: nullable(detail.productPerformance), + applicableScenarios: nullable(detail.applicableScenarios), + washingInstructions: nullable(detail.washingInstructions), + specialDescription: nullable(detail.specialDescription), + designExplanation: nullable(detail.designExplanation), + designArea: nullable(detail.designArea), + pictureRequest: nullable(detail.pictureRequest), + sizeChart: json(detail.sizeChart), + packageSpecs: json(detail.packageSpecs), + options: json(detail.options), + media: json(detail.media), + }; + } + + private async replaceCustomVariants( + tx: Prisma.TransactionClient, + originGoodId: bigint, + variants: CustomGoodVariantDto[], + ): Promise { + await tx.originGoodVariant.deleteMany({ where: { originGoodId } }); + for (const variant of variants) { + await tx.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `custom-${randomUUID()}`, + sku: variant.sku, + sizeId: variant.sizeId ?? null, + sizeName: variant.sizeName ?? null, + colorId: variant.colorId ?? null, + colorName: variant.colorName ?? null, + colorHex: variant.colorHex ?? null, + imageUrl: variant.imageUrl ?? null, + price: this.decimal(variant.price), + originalPrice: this.decimal(variant.originalPrice), + weightG: this.decimal(variant.weightG), + boxLengthCm: this.decimal(variant.boxLengthCm), + boxWidthCm: this.decimal(variant.boxWidthCm), + boxHeightCm: this.decimal(variant.boxHeightCm), + enabled: variant.enabled ?? true, + sortOrder: variant.sortOrder ?? 0, + designData: + variant.designData === null || variant.designData === undefined + ? Prisma.DbNull + : (variant.designData as Prisma.InputJsonValue), + }, + }); + } + } +} From d9ecd047476b33f1436824054f305f0d68d326ed Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:18:29 +0800 Subject: [PATCH 04/11] feat(origin-goods): include merged secondary references in tree stats --- .../origin-goods/origin-goods.service.spec.ts | 65 +++++++ .../src/origin-goods/origin-goods.service.ts | 171 +++++++++++------- 2 files changed, 168 insertions(+), 68 deletions(-) diff --git a/apps/api/src/origin-goods/origin-goods.service.spec.ts b/apps/api/src/origin-goods/origin-goods.service.spec.ts index 9b19a64..1adf7a4 100644 --- a/apps/api/src/origin-goods/origin-goods.service.spec.ts +++ b/apps/api/src/origin-goods/origin-goods.service.spec.ts @@ -77,4 +77,69 @@ describe('OriginGoodsService', () => { expect(result.total).toBe(0); expect(result.items.length).toBe(0); }); + + describe('getTree merged references', () => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + function findOgNode( + treeResponse: { tree: any[] }, + ogId: string, + ): { configuredCount: number; configuredCountries: string[] } { + let found: { configuredCount: number; configuredCountries: string[] } | null = null; + const walk = (nodes: any[]) => { + for (const n of nodes) { + const hit = (n.originGoods ?? []).find((o: any) => o.id === ogId); + if (hit) { + found = hit; + return; + } + if (n.children?.length) walk(n.children); + } + }; + walk(treeResponse.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}`; + await prisma.originGood.createMany({ + data: [ + { sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat }, + { sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat }, + ], + }); + createdSds.push(`tree-a-${stamp}`, `tree-b-${stamp}`); + const originA = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-a-${stamp}` } }); + const originB = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-b-${stamp}` } }); + + const cat = await prisma.category.create({ + data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat }, + }); + const country = await prisma.country.create({ + data: { countryName: `Tree Country ${stamp}` }, + }); + const good = await prisma.good.create({ + data: { + goodName: `Tree Good ${stamp}`, + originGoodId: originA.id, + countryId: country.id, + categoryId: cat.id, + }, + }); + await prisma.goodOriginGood.create({ + data: { goodId: good.id, originGoodId: originB.id }, + }); + + try { + const tree = await service.getTree(); + const nodeB = findOgNode(tree, originB.id.toString()); + expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1); + expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`); + } finally { + await prisma.good.delete({ where: { id: good.id } }).catch(() => undefined); + await prisma.country.delete({ where: { id: country.id } }).catch(() => undefined); + await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined); + } + }); + }); }); diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index 82db742..9b8d267 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -12,10 +12,10 @@ export interface PaginatedOriginGoods { goodPrice: string | null; sdsCategoryId: string | null; createdAt: string; - updatedAt: string; - hasDetail: boolean; - detailSyncedAt: string | null; - variantCount: number; + updatedAt: string; + hasDetail: boolean; + detailSyncedAt: string | null; + variantCount: number; }>; total: number; page: number; @@ -35,12 +35,12 @@ export interface OriginGoodsTreeNode { delisted: boolean; configuredCount: number; configuredCountries: string[]; - configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]; - hasDetail: boolean; - detailSyncedAt: string | null; - variantCount: number; - sizeRowCount: number; - packageRowCount: number; + configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]; + hasDetail: boolean; + detailSyncedAt: string | null; + variantCount: number; + sizeRowCount: number; + packageRowCount: number; } /** A category node in the hierarchical tree, with origin goods as leaves. */ @@ -67,19 +67,19 @@ export class OriginGoodsService { async findAll(query: QueryOriginGoodDto): Promise { const { page, pageSize, keyword } = query; - const where: Prisma.OriginGoodWhereInput = { - source: 'SDS', - ...(keyword - ? { goodName: { contains: keyword, mode: 'insensitive' as const } } - : {}), - }; + const where: Prisma.OriginGoodWhereInput = { + source: 'SDS', + ...(keyword + ? { goodName: { contains: keyword, mode: 'insensitive' as const } } + : {}), + }; const [total, rows] = await this.prisma.$transaction([ this.prisma.originGood.count({ where }), - this.prisma.originGood.findMany({ - where, - orderBy: { id: 'desc' }, - include: { detail: true, _count: { select: { variants: true } } }, + this.prisma.originGood.findMany({ + where, + orderBy: { id: 'desc' }, + include: { detail: true, _count: { select: { variants: true } } }, skip: (page - 1) * pageSize, take: pageSize, }), @@ -94,10 +94,10 @@ export class OriginGoodsService { goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(), sdsCategoryId: r.sdsCategoryId, createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), - hasDetail: Boolean(r.detail), - detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null, - variantCount: r._count.variants, + updatedAt: r.updatedAt.toISOString(), + hasDetail: Boolean(r.detail), + detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null, + variantCount: r._count.variants, })), total, page, @@ -114,7 +114,7 @@ export class OriginGoodsService { * under a synthetic "未分类" root node. */ async getTree(): Promise { - const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] = + const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] = await Promise.all([ this.prisma.category.findMany({ where: { sdsCategoryId: { not: null } }, @@ -126,11 +126,11 @@ export class OriginGoodsService { parentCategoryId: true, }, }), - this.prisma.originGood.findMany({ - where: { delisted: false, source: 'SDS' }, - orderBy: { goodName: 'asc' }, - include: { detail: true, _count: { select: { variants: true } } }, - }), + this.prisma.originGood.findMany({ + where: { delisted: false, source: 'SDS' }, + orderBy: { goodName: 'asc' }, + include: { detail: true, _count: { select: { variants: true } } }, + }), this.prisma.good.groupBy({ by: ['originGoodId'], _count: { _all: true }, @@ -144,7 +144,12 @@ export class OriginGoodsService { }), this.prisma.goodTag.findMany({ select: { - good: { select: { originGoodId: true } }, + good: { + select: { + originGoodId: true, + mergedOriginGoods: { select: { originGoodId: true } }, + }, + }, tag: { select: { tagName: true, @@ -157,26 +162,57 @@ export class OriginGoodsService { }, }, }), + // Secondary-source references (good_origin_goods) + this.prisma.goodOriginGood.groupBy({ + by: ['originGoodId'], + _count: { _all: true }, + }), + this.prisma.goodOriginGood.findMany({ + select: { + originGoodId: true, + good: { select: { country: { select: { countryName: true } } } }, + }, + }), ]); const countMap = new Map(); configCounts.forEach((c) => countMap.set(c.originGoodId.toString(), c._count._all), ); - - const countryMap = new Map(); - goodsWithCountries.forEach((g) => { - const key = g.originGoodId.toString(); - const name = g.country?.countryName; - if (!name) return; - const arr = countryMap.get(key); - if (arr) arr.push(name); - else countryMap.set(key, [name]); + // Secondary (merged) references count towards configured status too. + mergedCounts.forEach((c) => { + const key = c.originGoodId.toString(); + countMap.set(key, (countMap.get(key) ?? 0) + c._count._all); }); + const countryMap = new Map(); + const addCountry = (key: string, name?: string | null) => { + if (!name) return; + const arr = countryMap.get(key); + if (!arr?.includes(name)) { + countryMap.set(key, [...(arr ?? []), name]); + } + }; + goodsWithCountries.forEach((g) => + addCountry(g.originGoodId.toString(), g.country?.countryName), + ); + mergedWithCountries.forEach((m) => + addCountry(m.originGoodId.toString(), m.good.country?.countryName), + ); + const tagMap = new Map(); + const addTag = ( + key: string, + tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }, + ) => { + const arr = tagMap.get(key); + if (arr) { + if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo); + } else { + tagMap.set(key, [tagInfo]); + } + }; goodsWithTags.forEach((gt) => { - const key = gt.good.originGoodId.toString(); const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, @@ -185,11 +221,10 @@ export class OriginGoodsService { tagGroupName: gt.tag.tagGroup?.groupName ?? null, sortOrder: gt.tag.sortOrder, }; - const arr = tagMap.get(key); - if (arr) { - if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo); - } else { - tagMap.set(key, [tagInfo]); + addTag(gt.good.originGoodId.toString(), tagInfo); + // A good's tags also mark its secondary origin goods as configured. + for (const m of gt.good.mergedOriginGoods) { + addTag(m.originGoodId.toString(), tagInfo); } }); @@ -230,12 +265,12 @@ export class OriginGoodsService { delisted: og.delisted, configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCountries: countryMap.get(og.id.toString()) ?? [], - configuredTags: tagMap.get(og.id.toString()) ?? [], - hasDetail: Boolean(og.detail), - detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, - variantCount: og._count.variants, - sizeRowCount: this.jsonRows(og.detail?.sizeChart), - packageRowCount: this.jsonRows(og.detail?.packageSpecs), + configuredTags: tagMap.get(og.id.toString()) ?? [], + hasDetail: Boolean(og.detail), + detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, + variantCount: og._count.variants, + sizeRowCount: this.jsonRows(og.detail?.sizeChart), + packageRowCount: this.jsonRows(og.detail?.packageSpecs), })); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); @@ -281,12 +316,12 @@ export class OriginGoodsService { delisted: og.delisted, configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCountries: countryMap.get(og.id.toString()) ?? [], - configuredTags: tagMap.get(og.id.toString()) ?? [], - hasDetail: Boolean(og.detail), - detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, - variantCount: og._count.variants, - sizeRowCount: this.jsonRows(og.detail?.sizeChart), - packageRowCount: this.jsonRows(og.detail?.packageSpecs), + configuredTags: tagMap.get(og.id.toString()) ?? [], + hasDetail: Boolean(og.detail), + detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, + variantCount: og._count.variants, + sizeRowCount: this.jsonRows(og.detail?.sizeChart), + packageRowCount: this.jsonRows(og.detail?.packageSpecs), })), }); } @@ -297,16 +332,16 @@ export class OriginGoodsService { (og) => (countMap.get(og.id.toString()) ?? 0) > 0, ).length; - return { + return { tree, totalOriginGoods: allOriginGoods.length, configuredCount: totalConfigured, - }; - } - - private jsonRows(value: unknown): number { - if (!value || typeof value !== 'object' || !('rows' in value)) return 0; - const rows = (value as { rows?: unknown }).rows; - return Array.isArray(rows) ? rows.length : 0; - } -} + }; + } + + private jsonRows(value: unknown): number { + if (!value || typeof value !== 'object' || !('rows' in value)) return 0; + const rows = (value as { rows?: unknown }).rows; + return Array.isArray(rows) ? rows.length : 0; + } +} From f06dfffbdaa60d99341dfe3c48ede0cd23d492f3 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:20:25 +0800 Subject: [PATCH 05/11] feat(public): resolve goods by secondary sds id and merge variants --- apps/api/src/public/public.service.spec.ts | 343 ++++++++++++--------- apps/api/src/public/public.service.ts | 33 +- 2 files changed, 218 insertions(+), 158 deletions(-) diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 7a81ff1..f9b0caf 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -1,5 +1,5 @@ import { Test } from '@nestjs/testing'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PublicService } from './public.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -11,9 +11,9 @@ describe('PublicService', () => { let categoryId: bigint; let childCategoryId: bigint; let otherCategoryId: bigint; - let tagId: bigint; - let filterGroupIds: bigint[] = []; - let filterTagIds: bigint[] = []; + let tagId: bigint; + let filterGroupIds: bigint[] = []; + let filterTagIds: bigint[] = []; let originGoodId: bigint; let goodIds: bigint[] = []; @@ -98,51 +98,51 @@ describe('PublicService', () => { goodPriority: 1, }, }); - goodIds = [g1.id, g2.id, g3.id]; - - const craftGroup = await prisma.tagGroup.create({ - data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, - }); - const materialGroup = await prisma.tagGroup.create({ - data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, - }); - filterGroupIds = [craftGroup.id, materialGroup.id]; - const craftA = await prisma.tag.create({ - data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, - }); - const craftB = await prisma.tag.create({ - data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, - }); - const cotton = await prisma.tag.create({ - data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, - }); - filterTagIds = [craftA.id, craftB.id, cotton.id]; - await prisma.goodTag.createMany({ - data: [ - { goodId: g1.id, tagId: craftA.id }, - { goodId: g1.id, tagId: cotton.id }, - { goodId: g2.id, tagId: craftB.id }, - ], - }); - - await prisma.originGoodDetail.create({ - data: { - originGoodId, - productCode: 'OZ10827003', - productionProcess: '白墨烫画', - sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, - packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, - }, - }); - await prisma.originGoodVariant.create({ - data: { - originGoodId, - sdsVariantId: `pub-variant-${stamp}`, - sku: `OZ${stamp}`, - sizeName: 'S', - price: 38, - }, - }); + goodIds = [g1.id, g2.id, g3.id]; + + const craftGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, + }); + const materialGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, + }); + filterGroupIds = [craftGroup.id, materialGroup.id]; + const craftA = await prisma.tag.create({ + data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, + }); + const craftB = await prisma.tag.create({ + data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, + }); + const cotton = await prisma.tag.create({ + data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, + }); + filterTagIds = [craftA.id, craftB.id, cotton.id]; + await prisma.goodTag.createMany({ + data: [ + { goodId: g1.id, tagId: craftA.id }, + { goodId: g1.id, tagId: cotton.id }, + { goodId: g2.id, tagId: craftB.id }, + ], + }); + + await prisma.originGoodDetail.create({ + data: { + originGoodId, + productCode: 'OZ10827003', + productionProcess: '白墨烫画', + sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, + packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, + }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `pub-variant-${stamp}`, + sku: `OZ${stamp}`, + sizeName: 'S', + price: 38, + }, + }); // Seed a good in `otherCategory` so the "onlyHaveGoods" filter // returns more than one category. @@ -179,9 +179,9 @@ describe('PublicService', () => { await prisma.position.deleteMany({ where: { countryId }, }); - await prisma.tag.delete({ where: { id: tagId } }); - await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); - await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); + await prisma.tag.delete({ where: { id: tagId } }); + await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); + await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); await prisma.originGood.delete({ where: { id: originGoodId } }); // Delete children before parent (FK self-relation is RESTRICT). await prisma.category.delete({ where: { id: childCategoryId } }); @@ -220,139 +220,139 @@ describe('PublicService', () => { const filtered = await service.getGoods({ page: 1, pageSize: 50, - countryId: countryId.toString(), - categoryId: categoryId.toString(), // includes child + countryId: countryId.toString(), + categoryId: categoryId.toString(), // includes child keyword: `Pub `, }); expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); }); - it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { + it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { const result = await service.getGoods({ page: 1, pageSize: 50, - countryId: countryId.toString(), + countryId: countryId.toString(), keyword: `Pub `, }); const priorities = result.items.map((g) => g.goodPriority); // First verify primary descending priority. const sorted = [...priorities].sort((a, b) => b - a); expect(priorities).toEqual(sorted); - }); - - it('uses OR within one tag group and AND across tag groups', async () => { - const sameGroup = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - tags: [ - { - tagGroupId: filterGroupIds[0].toString(), - tagIds: filterTagIds.slice(0, 2).map(String), - }, - ], - }); - expect(sameGroup.items.map((item) => item.goodName)).toEqual( - expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), - ); - - const acrossGroups = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - tags: [ - { - tagGroupId: filterGroupIds[0].toString(), - tagIds: filterTagIds.slice(0, 2).map(String), - }, - { - tagGroupId: filterGroupIds[1].toString(), - tagIds: [filterTagIds[2].toString()], - }, - ], - }); - expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); - expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); - }); - - it('rejects a tag paired with the wrong tag group', async () => { - await expect( - service.getGoods({ - page: 1, - pageSize: 20, - tags: [ - { - tagGroupId: filterGroupIds[1].toString(), - tagIds: [filterTagIds[0].toString()], - }, - ], - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); + }); - it('returns the SDS product id as the public product id', async () => { + it('uses OR within one tag group and AND across tag groups', async () => { + const sameGroup = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tags: [ + { + tagGroupId: filterGroupIds[0].toString(), + tagIds: filterTagIds.slice(0, 2).map(String), + }, + ], + }); + expect(sameGroup.items.map((item) => item.goodName)).toEqual( + expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), + ); + + const acrossGroups = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tags: [ + { + tagGroupId: filterGroupIds[0].toString(), + tagIds: filterTagIds.slice(0, 2).map(String), + }, + { + tagGroupId: filterGroupIds[1].toString(), + tagIds: [filterTagIds[2].toString()], + }, + ], + }); + expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); + expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); + }); + + it('rejects a tag paired with the wrong tag group', async () => { + await expect( + service.getGoods({ + page: 1, + pageSize: 20, + tags: [ + { + tagGroupId: filterGroupIds[1].toString(), + tagIds: [filterTagIds[0].toString()], + }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns the SDS product id as the public product id', async () => { const result = await service.getGoods({ page: 1, pageSize: 1, - countryId: countryId.toString(), + countryId: countryId.toString(), keyword: `Pub High ${stamp}`, }); expect(result.items).toHaveLength(1); - expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); - expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); - }); - - it('returns custom goods through the same public product contract', async () => { - const customPublicId = `custom-public-${stamp}`; - const origin = await prisma.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: customPublicId, - goodName: `Pub Custom ${stamp}`, - goodPrice: 42, - detail: { create: { productCode: `CUSTOM-${stamp}` } }, - }, - }); - const good = await prisma.good.create({ - data: { - originGoodId: origin.id, - countryId, - categoryId, - goodName: `Pub Custom ${stamp}`, - }, - }); - try { - const detail = await service.getGood(customPublicId); - expect(detail.goodId).toBe(customPublicId); - expect(detail.goodName).toBe(`Pub Custom ${stamp}`); - expect(detail.productCode).toBe(`CUSTOM-${stamp}`); - } finally { - await prisma.good.delete({ where: { id: good.id } }); - await prisma.originGood.delete({ where: { id: origin.id } }); - } - }); + expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); + expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); + }); + + it('returns custom goods through the same public product contract', async () => { + const customPublicId = `custom-public-${stamp}`; + const origin = await prisma.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: customPublicId, + goodName: `Pub Custom ${stamp}`, + goodPrice: 42, + detail: { create: { productCode: `CUSTOM-${stamp}` } }, + }, + }); + const good = await prisma.good.create({ + data: { + originGoodId: origin.id, + countryId, + categoryId, + goodName: `Pub Custom ${stamp}`, + }, + }); + try { + const detail = await service.getGood(customPublicId); + expect(detail.goodId).toBe(customPublicId); + expect(detail.goodName).toBe(`Pub Custom ${stamp}`); + expect(detail.productCode).toBe(`CUSTOM-${stamp}`); + } finally { + await prisma.good.delete({ where: { id: good.id } }); + await prisma.originGood.delete({ where: { id: origin.id } }); + } + }); it('getGood returns detail and 404 for unknown id', async () => { const first = await service.getGoods({ page: 1, pageSize: 1, - countryId: countryId.toString(), + countryId: countryId.toString(), keyword: `Pub `, }); expect(first.items.length).toBe(1); - const detail = await service.getGood(`pub-sds-${stamp}`); - expect(detail.goodId).toBe(first.items[0].goodId); - expect(detail.productCode).toBe('OZ10827003'); - expect(detail.details.productionProcess).toBe('白墨烫画'); - expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); - expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); - expect(detail.variants).toHaveLength(1); - - await expect(service.getGood('99999999')).rejects.toBeInstanceOf( + const detail = await service.getGood(`pub-sds-${stamp}`); + expect(detail.goodId).toBe(first.items[0].goodId); + expect(detail.productCode).toBe('OZ10827003'); + expect(detail.details.productionProcess).toBe('白墨烫画'); + expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); + expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); + expect(detail.variants).toHaveLength(1); + + await expect(service.getGood('99999999')).rejects.toBeInstanceOf( NotFoundException, ); }); @@ -379,4 +379,39 @@ describe('PublicService', () => { const sortOrders = groups.map((g) => g.sortOrder); expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders); }); + + describe('merged secondary origin goods', () => { + 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}` }, + }); + const secVariant = await prisma.originGoodVariant.create({ + data: { + originGoodId: secondary.id, + sdsVariantId: `pub-var-sec-${stamp}`, + sku: `PUB-SEC-${stamp}`, + colorId: 'black', + colorName: '黑色', + imageUrl: 'http://img/black-sec', + }, + }); + // Attach as secondary source of the highest-priority fixture good. + await prisma.goodOriginGood.create({ + data: { goodId: goodIds[0], originGoodId: secondary.id }, + }); + + try { + const detail = await service.getGood(`pub-secondary-${stamp}`); + expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源 + expect(detail.variants.length).toBeGreaterThanOrEqual(2); + const black = detail.mediaByColor.find((g) => g.colorName === '黑色'); + expect(black).toBeTruthy(); + expect(black!.images).toContain('http://img/black-sec'); + } finally { + await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } }); + await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined); + await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); + } + }); + }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 45f11e0..37e74ba 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -34,6 +34,16 @@ const PUBLIC_GOOD_INCLUDE = { variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, }, + mergedOriginGoods: { + orderBy: { createdAt: 'asc' as const }, + include: { + originGood: { + include: { + variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + }, + }, + }, + }, goodTags: { include: { tag: { include: { tagGroup: true } } } }, } satisfies Prisma.GoodInclude; @@ -198,7 +208,17 @@ export class PublicService { async getGood(goodId: string): Promise { const good = await this.prisma.good.findFirst({ - where: { originGood: { sdsGoodId: goodId, delisted: false } }, + where: { + OR: [ + { originGood: { sdsGoodId: goodId, delisted: false } }, + // Merged secondary sources also resolve to the same good. + { + mergedOriginGoods: { + some: { originGood: { sdsGoodId: goodId, delisted: false } }, + }, + }, + ], + }, include: PUBLIC_GOOD_INCLUDE, orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }], }); @@ -277,7 +297,7 @@ export class PublicService { * design-layer素材图 and the product-level blank garment photo are excluded * because they are not per-color gallery photos. */ private groupImagesByColor( - variants: PublicGoodRow['originGood']['variants'], + variants: Array, ): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> { const groups = new Map m.originGood.variants), + ]; return { ...base, productCode: detail?.productCode ?? null, @@ -354,11 +379,11 @@ export class PublicService { pictureRequest: detail?.pictureRequest ?? null, }, media: (detail?.media as Record | null) ?? null, - mediaByColor: this.groupImagesByColor(good.originGood.variants), + mediaByColor: this.groupImagesByColor(allVariants), options: (detail?.options as Record | null) ?? null, sizeChart: (detail?.sizeChart as Record | null) ?? null, packageSpecs: (detail?.packageSpecs as Record | null) ?? null, - variants: good.originGood.variants.map((variant) => ({ + variants: allVariants.map((variant) => ({ id: variant.sdsVariantId, sku: variant.sku, sizeId: variant.sizeId, From 697b82a64bc4383c5a1cc3a6fc1510aa6ab23373 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:40:08 +0800 Subject: [PATCH 06/11] fix(admin): resolve pre-existing template type errors blocking build --- apps/admin/src/views/categories/CategoriesView.vue | 9 ++++----- apps/admin/src/views/countries/CountriesView.vue | 8 ++++---- apps/admin/src/views/positions/PositionsView.vue | 11 ++++------- apps/admin/src/views/tags/TagsView.vue | 13 ++++++------- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/admin/src/views/categories/CategoriesView.vue b/apps/admin/src/views/categories/CategoriesView.vue index 80ebaeb..79d2d0b 100644 --- a/apps/admin/src/views/categories/CategoriesView.vue +++ b/apps/admin/src/views/categories/CategoriesView.vue @@ -52,7 +52,6 @@ const dialogLoading = ref(false) const dialogForm = reactive({ categoryName: '', categoryIcon: '', - parentCategoryId: '', }) const dialogRules = { @@ -146,7 +145,7 @@ onMounted(fetchTree) > -