docs(specs): allow manual price overrides and custom family members
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 产品族(SPU)合并 — 后端设计
|
# 产品族(SPU)合并 — 后端设计
|
||||||
|
|
||||||
- 日期:2026-08-28
|
- 日期:2026-08-28(同日修订:价格允许人工覆盖、支持人工添加自定义商品入族)
|
||||||
- 状态:已评审(讨论稿定稿,待实施计划)
|
- 状态:已评审(讨论稿定稿,待实施计划)
|
||||||
- 分支:`feature/product-link-merge-yeuimu`
|
- 分支:`feature/product-link-merge-yeuimu`
|
||||||
- 范围:仅后端(apps/api);前台展示层(apps/admin、apps/website)仅列出依赖的接口契约
|
- 范围:仅后端(apps/api);前台展示层(apps/admin、apps/website)仅列出依赖的接口契约
|
||||||
@@ -34,7 +34,8 @@ SDS(InkPOD)上游会为同一个实体商品下发多条近似链接,名
|
|||||||
|
|
||||||
**非目标**
|
**非目标**
|
||||||
|
|
||||||
- 不引入价格阶梯/折扣/人工覆盖表(已被明确否决:完全按照链接价格透传);
|
- 不引入价格阶梯/折扣表(订单量定价已被明确否决);价格默认按链接推导透传,
|
||||||
|
但**允许人工按格改价(覆盖表)**,并支持**人工添加自定义商品入族**(见 §5.5、§5.6、§8);
|
||||||
- 不改动同步任务的核心抓取逻辑与安全护栏;
|
- 不改动同步任务的核心抓取逻辑与安全护栏;
|
||||||
- 不在本设计内实现 admin/website 的界面(仅约定接口契约)。
|
- 不在本设计内实现 admin/website 的界面(仅约定接口契约)。
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ SDS(InkPOD)上游会为同一个实体商品下发多条近似链接,名
|
|||||||
| # | 问题 | 决策 |
|
| # | 问题 | 决策 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| D1 | 合并落在哪一层 | **新增 SPU 产品族层**(介于 OriginGood 与 Good 之间),而非强化 Good 级合并或读取时动态聚合 |
|
| D1 | 合并落在哪一层 | **新增 SPU 产品族层**(介于 OriginGood 与 Good 之间),而非强化 Good 级合并或读取时动态聚合 |
|
||||||
| D2 | 价格形态 | **完全按链接透传**:无阶梯折扣、无明码矩阵、无人工覆盖;价格 = 成员链接变体的 SDS 原价 |
|
| D2 | 价格形态 | **默认按链接透传 + 人工覆盖**(2026-08-28 修订):无阶梯折扣、无全手动明码矩阵;价格默认 = 成员链接变体的 SDS 原价,允许按格子人工改价(覆盖表),并允许人工添加自定义商品入族 |
|
||||||
| D3 | 买家选价粒度 | **五维全选**:详情页提供尺码/颜色/工艺(印花数量)/物流选择器,价格实时联动 |
|
| D3 | 买家选价粒度 | **五维全选**:详情页提供尺码/颜色/工艺(印花数量)/物流选择器,价格实时联动 |
|
||||||
|
|
||||||
## 4. 架构
|
## 4. 架构
|
||||||
@@ -132,7 +133,8 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
"sizeId": "size_3", "sizeName": "XXXL",
|
"sizeId": "size_3", "sizeName": "XXXL",
|
||||||
"colorId": "color_2", "colorName": "黑色",
|
"colorId": "color_2", "colorName": "黑色",
|
||||||
"craft": "单面印花", "logistics": "包邮",
|
"craft": "单面印花", "logistics": "包邮",
|
||||||
"price": "25.00", // Decimal 字符串,来自 OriginGoodVariant.price
|
"price": "25.00", // 有效价(Decimal 字符串):推导价或人工覆盖价
|
||||||
|
"manual": false, // true = 该格价格来自 FamilyPriceOverride
|
||||||
"sources": [ // 同格子的全部来源(后台核对下单路由用)
|
"sources": [ // 同格子的全部来源(后台核对下单路由用)
|
||||||
{ "sdsGoodId": "123", "sdsVariantId": "456", "price": "25.00" }
|
{ "sdsGoodId": "123", "sdsVariantId": "456", "price": "25.00" }
|
||||||
]
|
]
|
||||||
@@ -141,6 +143,46 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5.5 价格覆盖表(人工改价)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model FamilyPriceOverride {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
|
||||||
|
familyId BigInt @map("family_id")
|
||||||
|
sizeId String @map("size_id") // 精确格,不做通配
|
||||||
|
colorId String @map("color_id")
|
||||||
|
craft String @map("craft")
|
||||||
|
logistics String @map("logistics")
|
||||||
|
price Decimal @db.Decimal(12, 2) // 人工价,仅校验为正数,不限制方向
|
||||||
|
note String? // 改价原因备注(审计)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([familyId, sizeId, colorId, craft, logistics])
|
||||||
|
@@map("family_price_overrides")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 覆盖表**独立于推导矩阵**:重算只重建推导矩阵,覆盖表永不被动过;
|
||||||
|
- 有效价 = 覆盖命中 ? 覆盖价 : 推导价,物化时合并进 `priceMatrix`(命中行标记 `manual: true`);
|
||||||
|
- 覆盖可命中推导矩阵中**不存在**的格子(人工补组合/补价),但各维度取值必须来自该族
|
||||||
|
的选项集合(sizes/colors/crafts/logistics),校验失败返回 400;
|
||||||
|
- 删除覆盖即恢复推导价,无任何副作用。
|
||||||
|
|
||||||
|
### 5.6 自定义成员(人工添加商品)
|
||||||
|
|
||||||
|
复用现有 `OriginGood.source = CUSTOM` 机制(`POST /goods/custom` 已存在),扩展为可入族:
|
||||||
|
|
||||||
|
- 自定义 OriginGood 与 SDS 链接一样通过 `familyId` 挂族;其变体(尺码/颜色/价格)、
|
||||||
|
详情、尺码表、包装规则全部为人工数据;
|
||||||
|
- 四个解析列(skuCode/logistics/craft/warehouse)由管理员创建时**手工填写**,
|
||||||
|
其中物流、工艺为必填(价格矩阵归因依赖);
|
||||||
|
- 重算语义:自定义成员的变体与详情是**人工数据、只读贡献**——并集与矩阵推导时读取,
|
||||||
|
永不覆盖或删除;仅当管理员显式移出族或删除该商品时失效;
|
||||||
|
- 自动建族(3 段规则)不会把 CUSTOM 成员吸进族,归属完全由人工决定。
|
||||||
|
|
||||||
## 6. 链接名结构化解析
|
## 6. 链接名结构化解析
|
||||||
|
|
||||||
输入:`OriginGood.goodName`(`国家(物流备注)品名-SKU-工艺位置[-仓库名]`)。
|
输入:`OriginGood.goodName`(`国家(物流备注)品名-SKU-工艺位置[-仓库名]`)。
|
||||||
@@ -162,7 +204,7 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
- **包装规则并集**:同样按尺码对齐合并 `packageSpecs`,冲突规则同上。
|
- **包装规则并集**:同样按尺码对齐合并 `packageSpecs`,冲突规则同上。
|
||||||
- **商品详情**:canonical 版本默认取主链接(管理员可指定),并集只作用于尺码表与包装规则。
|
- **商品详情**:canonical 版本默认取主链接(管理员可指定),并集只作用于尺码表与包装规则。
|
||||||
- **同格子价格冲突**(同物流+同工艺、不同仓库的链接,同尺码颜色不同价):取**最低价**,
|
- **同格子价格冲突**(同物流+同工艺、不同仓库的链接,同尺码颜色不同价):取**最低价**,
|
||||||
`sources` 保留全部来源。
|
`sources` 保留全部来源;在此基础上若存在人工覆盖,以覆盖价为最终有效价(§5.5)。
|
||||||
|
|
||||||
## 8. 价格模型
|
## 8. 价格模型
|
||||||
|
|
||||||
@@ -170,13 +212,14 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
第一次:选链接 → 物流 + 工艺(印花数量) (一个组合对应一条/多条成员链接)
|
第一次:选链接 → 物流 + 工艺(印花数量) (一个组合对应一条/多条成员链接)
|
||||||
第二次:选变体 → 尺码 + 颜色 (链接下的 SDS 变体)
|
第二次:选变体 → 尺码 + 颜色 (链接下的 SDS 变体)
|
||||||
|
|
||||||
最终价 = 该 (物流, 工艺) 组合下该 (尺码, 颜色) 变体的 SDS 价格,原样透传
|
推导价 = 该 (物流, 工艺) 组合下该 (尺码, 颜色) 变体的 SDS 价格,原样透传
|
||||||
|
有效价 = 覆盖表命中 ? 覆盖价 : 推导价 // 人工改价入口,见 §5.5
|
||||||
```
|
```
|
||||||
|
|
||||||
- 推导过程:每条成员链接按 `(logisticsLabel, craftLabel)` 归因 → 其变体按
|
- 推导过程:每条成员链接按 `(logisticsLabel, craftLabel)` 归因 → 其变体按
|
||||||
`(sizeId, colorId)` 落格 → 去重(冲突取最低价)→ 物化为 `priceMatrix`;
|
`(sizeId, colorId)` 落格 → 去重(冲突取最低价)→ 物化为 `priceMatrix`;
|
||||||
- 展示"起价" = 全矩阵最低格价格;
|
- 展示"起价" = 有效矩阵(推导 ∪ 覆盖)最低格价格;
|
||||||
- 不存在任何人工定价入口,无阶梯折扣表。
|
- 阶梯折扣表不存在;人工定价仅有覆盖表按格修改一个入口(§5.5)。
|
||||||
|
|
||||||
## 9. 同步联动与重算
|
## 9. 同步联动与重算
|
||||||
|
|
||||||
@@ -186,7 +229,9 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
2. 重算内容:并集尺码表、并集包装规则、价格矩阵、族选项(crafts/logistics 去重列表);
|
2. 重算内容:并集尺码表、并集包装规则、价格矩阵、族选项(crafts/logistics 去重列表);
|
||||||
3. `autoManaged=true`:直接重算覆盖镜像物化字段;
|
3. `autoManaged=true`:直接重算覆盖镜像物化字段;
|
||||||
4. `autoManaged=false`:只置 `stale=true`,绝不静默覆盖人工数据;
|
4. `autoManaged=false`:只置 `stale=true`,绝不静默覆盖人工数据;
|
||||||
5. 幂等性:重算输入只有成员链接的镜像数据,同一状态重算结果相同,可安全重试。
|
5. 幂等性:重算输入均为确定性数据(成员镜像、覆盖表、自定义成员),同一状态重算结果相同,可安全重试;
|
||||||
|
6. 覆盖表(§5.5)与自定义成员数据(§5.6)在重算中**只读**,永不覆盖;
|
||||||
|
物化 `priceMatrix` 时把覆盖合并为最终有效矩阵(命中行 `manual: true`)。
|
||||||
|
|
||||||
商品列表同步(`syncProducts`)在 upsert `OriginGood` 时同步刷新四个解析列;
|
商品列表同步(`syncProducts`)在 upsert `OriginGood` 时同步刷新四个解析列;
|
||||||
链接被标记 `delisted` 时,其族在下一次重算中自然剔除该成员的变体与尺码贡献。
|
链接被标记 `delisted` 时,其族在下一次重算中自然剔除该成员的变体与尺码贡献。
|
||||||
@@ -203,8 +248,15 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
| `PATCH /product-families/:id` | 编辑 canonical 字段(名称/主图/国家/分类/详情/主链接/autoManaged) |
|
| `PATCH /product-families/:id` | 编辑 canonical 字段(名称/主图/国家/分类/详情/主链接/autoManaged) |
|
||||||
| `POST /product-families/:id/members` | 增删成员 `{ addOriginGoodIds, removeOriginGoodIds }`,变更后触发重算 |
|
| `POST /product-families/:id/members` | 增删成员 `{ addOriginGoodIds, removeOriginGoodIds }`,变更后触发重算 |
|
||||||
| `POST /product-families/:id/recompute` | 手动重算 |
|
| `POST /product-families/:id/recompute` | 手动重算 |
|
||||||
|
| `GET /product-families/:id/price-overrides` | 覆盖列表(含推导价对照、差异标记) |
|
||||||
|
| `PUT /product-families/:id/price-overrides` | 批量 upsert 人工价 `{ items: [{ sizeId, colorId, craft, logistics, price, note }] }`,写后触发重物化 |
|
||||||
|
| `DELETE /product-families/:id/price-overrides` | 按格删除覆盖 `{ cells: [...] }`,恢复推导价 |
|
||||||
|
| `POST /product-families/:id/members/custom` | 在族内创建自定义成员(人工商品:名称/主图/物流/工艺归因/变体价格/详情) |
|
||||||
| `GET /origin-goods/tree` | 改为按**真实族**分组展示,替代名称截断的临时分组 |
|
| `GET /origin-goods/tree` | 改为按**真实族**分组展示,替代名称截断的临时分组 |
|
||||||
|
|
||||||
|
`POST /goods/custom` 保留并扩展:请求体增加解析列(物流/工艺必填)与结构化变体
|
||||||
|
(尺码/颜色/价格),创建后可直接指定 `familyId` 挂族;未指定则形成单成员族。
|
||||||
|
|
||||||
### 10.2 公开(无鉴权)
|
### 10.2 公开(无鉴权)
|
||||||
|
|
||||||
`GET /public/goods/:goodId`(goodId 仍为 sdsGoodId,主源或副源命中均可,语义不变)详情新增:
|
`GET /public/goods/:goodId`(goodId 仍为 sdsGoodId,主源或副源命中均可,语义不变)详情新增:
|
||||||
@@ -219,7 +271,7 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
"logisticsOptions": ["包邮", "专线"],
|
"logisticsOptions": ["包邮", "专线"],
|
||||||
"sizeChart": { /* 并集 */ },
|
"sizeChart": { /* 并集 */ },
|
||||||
"packageSpecs": { /* 并集 */ },
|
"packageSpecs": { /* 并集 */ },
|
||||||
"priceMatrix": { /* §5.4 结构,嵌入详情而非单独查价接口 */ }
|
"priceMatrix": { /* §5.4 结构(含 manual 标记),嵌入详情而非单独查价接口 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -239,7 +291,9 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
(主源+副源)对应到族(恰好匹配自动族则直接关联;横跨多组则建独立族),回填 `Good.familyId`;
|
(主源+副源)对应到族(恰好匹配自动族则直接关联;横跨多组则建独立族),回填 `Good.familyId`;
|
||||||
5. **物化回填**:对全部族跑一次重算,填充并集与价格矩阵;
|
5. **物化回填**:对全部族跑一次重算,填充并集与价格矩阵;
|
||||||
6. **读路径灰度**:开关切换 `/public/goods/:goodId` 详情来源;确认无回归后,
|
6. **读路径灰度**:开关切换 `/public/goods/:goodId` 详情来源;确认无回归后,
|
||||||
`good_origin_goods` 转只读观察一段时间再废弃。
|
`good_origin_goods` 转只读观察一段时间再废弃;
|
||||||
|
7. **自定义商品与覆盖**:存量 `source=CUSTOM` 的 OriginGood 解析列保持为空,由管理员
|
||||||
|
按需补填并挂族;覆盖表初始为空,随运营逐步产生,无需数据迁移。
|
||||||
|
|
||||||
分期建议:一期(族 + 解析 + 自动建族 + 后台树改造),二期(并集物化 + 公开详情切换 + Good.familyId),
|
分期建议:一期(族 + 解析 + 自动建族 + 后台树改造),二期(并集物化 + 公开详情切换 + Good.familyId),
|
||||||
避免单次变更过大。
|
避免单次变更过大。
|
||||||
@@ -250,6 +304,9 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
- 并集合并:同尺码冲突裁决、缺尺码补齐、包装规则合并、warning 记录;
|
- 并集合并:同尺码冲突裁决、缺尺码补齐、包装规则合并、warning 记录;
|
||||||
- 价格矩阵推导:归因正确性、同格子最低价、delisted 成员剔除、幂等重算;
|
- 价格矩阵推导:归因正确性、同格子最低价、delisted 成员剔除、幂等重算;
|
||||||
- 锁定语义:`autoManaged=false` 的族在重算时只置 stale、字段不被覆盖;
|
- 锁定语义:`autoManaged=false` 的族在重算时只置 stale、字段不被覆盖;
|
||||||
|
- 覆盖语义:重算后覆盖价保留且生效;覆盖可新增推导矩阵中不存在的格子(校验维度取值);
|
||||||
|
删除覆盖恢复推导价;覆盖行在公开矩阵中带 `manual` 标记;
|
||||||
|
- 自定义成员:变体/详情不被重算覆盖;物流/工艺归因正确进入矩阵;自动建族不吸收 CUSTOM 成员;
|
||||||
- 迁移映射:现有 Good 合并关系到族的映射(恰好匹配 / 横跨多组);
|
- 迁移映射:现有 Good 合并关系到族的映射(恰好匹配 / 横跨多组);
|
||||||
- 公开接口契约:开关两态下的响应形状、任何成员 sdsGoodId 均命中同一族;
|
- 公开接口契约:开关两态下的响应形状、任何成员 sdsGoodId 均命中同一族;
|
||||||
- 全量现有测试保持通过(回归红线)。
|
- 全量现有测试保持通过(回归红线)。
|
||||||
@@ -271,4 +328,6 @@ familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id
|
|||||||
1. 同格子多链接价格冲突 → **取最低价**;
|
1. 同格子多链接价格冲突 → **取最低价**;
|
||||||
2. 尺码测量值冲突 → **尺码覆盖最全的成员优先**;
|
2. 尺码测量值冲突 → **尺码覆盖最全的成员优先**;
|
||||||
3. 单链接是否建族 → **是**(统一不变量);
|
3. 单链接是否建族 → **是**(统一不变量);
|
||||||
4. `familyCode` 唯一性 → 全局唯一,允许为空(历史/畸形数据)。
|
4. `familyCode` 唯一性 → 全局唯一,允许为空(历史/畸形数据);
|
||||||
|
5. 覆盖粒度 → 精确格 `(sizeId, colorId, craft, logistics)`,不做通配符;
|
||||||
|
6. 覆盖价方向 → 不限制(可高于或低于推导价),仅校验为正数。
|
||||||
|
|||||||
Reference in New Issue
Block a user