fix: require saved spec match before purchase (#188)
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: b9d13a3576f331091687bca1d6407f9d41901fad
|
||||
synchronized_at: 2026-09-01T06:41:08Z
|
||||
wiki_revision: 687439543630832db26d2c73f6ada980e34eea10
|
||||
synchronized_at: 2026-09-01T07:28:03Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -31,7 +31,7 @@ synchronized_at: 2026-09-01T06:41:08Z
|
||||
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
|
||||
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
|
||||
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称完全相等自动预填)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量 AI 匹配;`parse_status=uncertain` 必须先人工修正。只有 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`;低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量匹配;`parse_status=uncertain` 必须先人工修正。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
|
||||
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
@@ -53,8 +53,8 @@ synchronized_at: 2026-09-01T06:41:08Z
|
||||
### SYB 商品处理阶段
|
||||
|
||||
- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、颜色待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 颜色待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
@@ -135,7 +135,7 @@ synchronized_at: 2026-09-01T06:41:08Z
|
||||
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
|
||||
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
|
||||
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
|
||||
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性匹配,禁止调用 AI Provider。正式创建仍按最新数据完整复核,只有创建阶段无法唯一确定规格时才允许调用 AI。
|
||||
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性识别,禁止调用 AI Provider。未保存的确定性结果仍显示“规格待匹配”,不能作为“可创建采购”的依据。SYB 批量创建采购按最新数据完整复核,只有目标颜色和尺码都已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时才允许创建;否则必须先执行批量 AI 匹配或人工确认,不能把未匹配明细带入采购任务内的人工匹配步骤。
|
||||
- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。
|
||||
- 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。
|
||||
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 5afa14ea875e38dc08718a19d4646147a56d829f
|
||||
synchronized_at: 2026-09-01T06:42:33Z
|
||||
wiki_revision: b3d7656c10bb3ec93894f69dbe078c5aa3037ef7
|
||||
synchronized_at: 2026-09-01T07:28:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -54,7 +54,7 @@ POST /api/admin/v1/shopee-products/batch-delete
|
||||
|
||||
创建与关联/映射相关写操作均提交 `requestId` 做幂等重放;重放请求返回相同结果并标记 `replayed`。创建请求提交 `shopeeItemId`、`title`、`shopName`,可选 `pddProductId` 和 `specs`;`shopeeItemId` 重复时返回 `SHOPEE_ITEM_ID_EXISTS` 和已存在商品 ID。`link-pdd` 校验 PDD 商品存在且非 `disabled`,否则分别返回 `PDD_PRODUCT_NOT_FOUND` 或 `PDD_PRODUCT_DISABLED`;关联成功后返回值包含 `sharedByPddCount`,表示当前共用同一 PDD 商品的虾皮商品数。
|
||||
|
||||
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 改变;#188 仅通过下述采购批量规格匹配接口的独立写入路径,在高置信度门槛通过时写入 `confirmed`。
|
||||
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 改变;#188 仅通过下述采购批量规格匹配接口的独立写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `confirmed`。
|
||||
|
||||
`preview-auto-size` 是只读计算接口(使用 `POST` 触发计算,不写数据库),无需 `requestId`。它读取当前蝦皮尺码与关联 PDD 的可选尺码,只返回格式统一后唯一确定的匹配;响应含 `items[]`(`valueName`、可选 `pddValue`、`status=preserved|matched|pending`、`reason`)、`pddValues`、`matchedCount` 和 `pendingCount`。已确认且目标仍存在的映射标为 `preserved`;无唯一结果标为 `pending`。Admin 的显式“保存修改”仍通过既有设置/确认接口落库。
|
||||
|
||||
@@ -501,8 +501,8 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与 AI 匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;`parse_status=uncertain` 不进入自动确认。只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时,才通过独立路径把该明细所需映射写为 `confirmed`;低置信度、无效组合和 Provider 异常不改写现有映射 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;`parse_status=uncertain` 不进入自动确认。唯一确定性 `exact_match` 不调用 Provider,直接通过独立路径保存为 `confirmed`;其余结果只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时才保存为 `confirmed`。低置信度、无效组合和 Provider 异常不改写现有映射 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 |
|
||||
|
||||
@@ -23,6 +23,7 @@ type aiMatchQualification struct {
|
||||
Request aimatching.MatchRequest
|
||||
MappedColor string
|
||||
MappedSize string
|
||||
Deterministic *aimatching.MatchResult
|
||||
}
|
||||
|
||||
type skuCombinationRow struct {
|
||||
@@ -144,6 +145,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
return disabled("蝦皮商品中未找到目标尺码,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if len(dataset.skuCombinationsByPDD[pdd.ID]) == 0 {
|
||||
return disabled("缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集")
|
||||
}
|
||||
if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
color, size := mappedColor, mappedSize
|
||||
if request.TargetColor != "" {
|
||||
@@ -153,12 +157,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
size = matched.MappedSize
|
||||
}
|
||||
if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) {
|
||||
return disabled("已有唯一确定性匹配结果,无需 AI 匹配")
|
||||
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize, Deterministic: &matched}
|
||||
}
|
||||
}
|
||||
if len(dataset.skuCombinationsByPDD[pdd.ID]) == 0 {
|
||||
return disabled("缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集")
|
||||
}
|
||||
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
eligible := 0
|
||||
for _, id := range ids {
|
||||
item := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
item.enforcePersistedMatch(id, dataset)
|
||||
item.applyCollectionEligibility(id, dataset)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
item.AIMatchEligible = qualification.Eligible
|
||||
@@ -353,7 +354,9 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
return s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
item := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
item.enforcePersistedMatch(id, dataset)
|
||||
return item
|
||||
}
|
||||
|
||||
func replayBatchPreview(task models.PurchaseTask) BatchPreviewItem {
|
||||
@@ -474,6 +477,46 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
return item
|
||||
}
|
||||
|
||||
// enforcePersistedMatch is the #188 gate for the SYB list and batch-create
|
||||
// workflow. A transient deterministic result is not purchase readiness: the
|
||||
// color/size mapping must already be confirmed on the Shopee product and must
|
||||
// still identify one complete, available SKU combination from the latest
|
||||
// successful collection.
|
||||
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
|
||||
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
|
||||
return
|
||||
}
|
||||
syb, ok := dataset.sybByID[id]
|
||||
if !ok || syb.ShopeeProductID == nil {
|
||||
return
|
||||
}
|
||||
shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !ok || shopee.PDDProductID == nil {
|
||||
return
|
||||
}
|
||||
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !ok || pdd.Status != "active" {
|
||||
return
|
||||
}
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
item.MappedColor, item.MappedSize = mappedColor, mappedSize
|
||||
if source == "unresolved" {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格尚未匹配并保存,请先完成 AI 匹配或人工确认", "open_mapping"
|
||||
return
|
||||
}
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if len(combinations) == 0 {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集", "open_pdd"
|
||||
return
|
||||
}
|
||||
if !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
|
||||
}
|
||||
}
|
||||
|
||||
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
|
||||
var dimensions []productspec.Dimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
|
||||
@@ -73,15 +73,21 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
pdd := dataset.pddByID[*shopee.PDDProductID]
|
||||
request := qualification.Request
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.Reason = batchSpecMatchErrorMessage(matchErr)
|
||||
response.FailedCount++
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
var matched aimatching.MatchResult
|
||||
if qualification.Deterministic != nil {
|
||||
matched = *qualification.Deterministic
|
||||
} else {
|
||||
var matchErr error
|
||||
matched, matchErr = s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.Reason = batchSpecMatchErrorMessage(matchErr)
|
||||
response.FailedCount++
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
item.Source, item.Confidence = matched.Source, matched.Decision.Confidence
|
||||
autoConfirm := matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != ""
|
||||
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "")
|
||||
if !autoConfirm {
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认"
|
||||
if strings.TrimSpace(matched.Decision.Reason) != "" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -95,6 +96,9 @@ func seedAvailableSKUCombinations(t *testing.T, db *gorm.DB, pddID uint64, combi
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func aiBatchResult(confidence float64) aimatching.MatchResult {
|
||||
@@ -117,6 +121,60 @@ func savedColorMapping(t *testing.T, service *Service, productID uint64) *shopee
|
||||
return specs[0].Values[0].Mapping
|
||||
}
|
||||
|
||||
func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", pddSpecs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopeeSpecs := `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", shopeeSpecs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedAvailableSKUCombinations(t, db, f.pdd.ID, []pddSKUCombination{{Color: "黑色", Size: "XL"}})
|
||||
return testService(db), f
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
|
||||
service, f := exactBatchSpecFixture(t)
|
||||
matcher := &batchSpecMatcher{}
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err)
|
||||
}
|
||||
blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired {
|
||||
t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err)
|
||||
}
|
||||
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || matched.AutoConfirmedCount != 1 || matched.Items[0].Source != aimatching.SourceExact || matcher.calls != 0 {
|
||||
t.Fatalf("exact match was not persisted locally: %+v calls=%d err=%v", matched, matcher.calls, err)
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := service.DB.First(&product, f.shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil || len(specs) != 2 || specs[0].Values[0].Mapping == nil || specs[1].Values[0].Mapping == nil {
|
||||
t.Fatalf("exact mappings were not saved: %+v err=%v", specs, err)
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
mapping := dimension.Values[0].Mapping
|
||||
if mapping.Source != shopeeproduct.MappingSourceExactMatch || mapping.Status != shopeeproduct.MappingStatusConfirmed {
|
||||
t.Fatalf("unexpected saved exact mapping: %+v", mapping)
|
||||
}
|
||||
}
|
||||
after, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || !after.Items[0].Eligible || after.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("persisted exact match did not unlock purchase: %+v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchAutoConfirmsHighConfidenceAndRefreshesReadiness(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
duplicate := f.syb
|
||||
|
||||
@@ -277,4 +277,5 @@ func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedAvailableSKUCombinations(t, db, productID, []pddSKUCombination{{Color: "黑色", Size: "XL"}})
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ var processStageLabels = map[string]string{
|
||||
ProcessStagePDDPending: "PDD 待采集",
|
||||
ProcessStagePDDCollecting: "PDD 采集中",
|
||||
ProcessStagePDDCollectionFail: "PDD 采集失败",
|
||||
ProcessStageColorMapping: "颜色待匹配",
|
||||
ProcessStageColorMapping: "规格待匹配",
|
||||
ProcessStagePurchaseReady: "可创建采购",
|
||||
ProcessStageTaskCreated: "已创建任务",
|
||||
ProcessStagePurchaseSucceeded: "采购成功",
|
||||
@@ -70,6 +70,10 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
|
||||
}
|
||||
for _, id := range ids {
|
||||
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
preview.enforcePersistedMatch(id, dataset)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
preview.AIMatchEligible = qualification.Eligible
|
||||
preview.AIMatchDisabledReason = qualification.DisabledReason
|
||||
if ruleReason != "" && preview.Eligible {
|
||||
preview.Eligible = false
|
||||
preview.ReasonCode = purchaserule.CodeCurrentRuleMissing
|
||||
@@ -166,7 +170,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
}
|
||||
if preview.ReasonCode == CodeMappingRequired {
|
||||
if preview.AIMatchEligible {
|
||||
return stage(ProcessStageColorMapping, "采购规格待 AI 匹配或人工确认", "open_mapping")
|
||||
return stage(ProcessStageColorMapping, "采购规格尚未匹配并保存", "open_mapping")
|
||||
}
|
||||
reason := strings.TrimSpace(preview.AIMatchDisabledReason)
|
||||
if reason == "" {
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
|
||||
pddID := uint64(3)
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: &pddID, Status: models.TaskStatusFailed}
|
||||
}},
|
||||
{"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) {
|
||||
{"规格待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) {
|
||||
p.ReasonCode = CodeMappingRequired
|
||||
p.AIMatchEligible = true
|
||||
}},
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
)
|
||||
|
||||
// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB
|
||||
// batch-match entry point. Confirmed writes are intentionally restricted to
|
||||
// auditable AI decisions; the general SetMapping path remains pending-first.
|
||||
// batch-match entry point. Confirmed writes are restricted to auditable exact
|
||||
// matches and high-confidence AI decisions; SetMapping remains pending-first.
|
||||
type ResolvedMappingItem struct {
|
||||
Dimension string
|
||||
ValueName string
|
||||
@@ -38,8 +38,11 @@ func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, re
|
||||
if item.Source != MappingSourceExactMatch && item.Source != MappingSourceAIMatch {
|
||||
return SaveResponse{}, invalidRequest("批量匹配来源无效")
|
||||
}
|
||||
if item.Status != MappingStatusConfirmed || item.Source != MappingSourceAIMatch || item.Confidence == nil || *item.Confidence < minimumConfidence || strings.TrimSpace(item.Reason) == "" {
|
||||
return SaveResponse{}, invalidRequest("自动确认仅允许带置信度和理由的 AI 匹配")
|
||||
if item.Status != MappingStatusConfirmed || strings.TrimSpace(item.Reason) == "" {
|
||||
return SaveResponse{}, invalidRequest("自动确认映射必须包含确认状态和匹配理由")
|
||||
}
|
||||
if item.Source == MappingSourceAIMatch && (item.Confidence == nil || *item.Confidence < minimumConfidence) {
|
||||
return SaveResponse{}, invalidRequest("AI 自动确认必须达到置信度阈值")
|
||||
}
|
||||
if err := service.validatePDDMappingTarget(ctx, id, item.Dimension, item.ValueName, strings.TrimSpace(item.PDDValue)); err != nil {
|
||||
return SaveResponse{}, err
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="AI 匹配只处理服务端确认已成功解析、已关联 PDD 且具备完整可售 SKU 组合的明细;三个批量动作只处理各自适用项,表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
@@ -199,7 +199,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '颜色待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||
aiMatchButtonReason() {
|
||||
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
||||
@@ -278,7 +278,7 @@ export default {
|
||||
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '颜色待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
|
||||
@@ -11,7 +11,7 @@ test('PDD 详情扁平展示关联订单并只提交可采购子集', async({ pa
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/1/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 11, orderCode: 'ORDER-11', shopeeProductId: 21, shopeeItemId: 'S21', productTitle: '关联虾皮商品', targetColor: '黑色', targetSize: 'XL', quantity: 2, eligible: true, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, { sybProductId: 12, orderCode: 'ORDER-12', shopeeProductId: 21, shopeeItemId: 'S21', productTitle: '关联虾皮商品', targetColor: '白色', targetSize: 'L', quantity: 1, eligible: false, processStage: 'color_mapping', processStageLabel: '颜色待匹配', processStageReason: '颜色匹配无效' }], total: 2, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/1/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 11, orderCode: 'ORDER-11', shopeeProductId: 21, shopeeItemId: 'S21', productTitle: '关联虾皮商品', targetColor: '黑色', targetSize: 'XL', quantity: 2, eligible: true, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, { sybProductId: 12, orderCode: 'ORDER-12', shopeeProductId: 21, shopeeItemId: 'S21', productTitle: '关联虾皮商品', targetColor: '白色', targetSize: 'L', quantity: 1, eligible: false, processStage: 'color_mapping', processStageLabel: '规格待匹配', processStageReason: '规格匹配无效' }], total: 2, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/1')) return route.fulfill({ json: { code: 200, data: { product, relatedShopeeProducts: [{ shopeeProductId: 21, shopeeItemId: 'S21', title: '关联虾皮商品', specMappingStatus: 'incomplete', specMappingConfirmedCount: 1, specMappingTotalCount: 2, specMappingReason: '已确认 1/2 个规格值', actionableOrderCount: 2 }] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) { previewBodies.push(route.request().postDataJSON()); return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 11, orderCode: 'ORDER-11', eligible: true }], eligibleCount: 1, skippedCount: 0 }}}) }
|
||||
if (url.pathname.endsWith('/api/admin/v1/devices')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
|
||||
|
||||
@@ -99,13 +99,13 @@ test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', asyn
|
||||
items: [
|
||||
{ sybProductId: 81, orderCode: 'MATCH-81', status: 'auto_confirmed', source: 'ai_match', confidence: 0.96, reason: '唯一可靠候选' },
|
||||
{ sybProductId: 82, orderCode: 'MATCH-82', status: 'pending', source: 'ai_match', confidence: 0.61, reason: '未达到自动确认阈值' },
|
||||
{ sybProductId: 83, orderCode: 'READY-83', status: 'skipped', reason: '当前不是颜色待匹配阶段,无需处理' }
|
||||
{ sybProductId: 83, orderCode: 'READY-83', status: 'skipped', reason: '当前不是规格待匹配阶段,无需处理' }
|
||||
], autoConfirmedCount: 1, pendingCount: 1, failedCount: 0, skippedCount: 1
|
||||
}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) {
|
||||
const stage = (id: number) => id === 83 || (id === 81 && matched) ? 'purchase_ready' : 'color_mapping'
|
||||
const items = [81, 82, 83].map(id => ({ sybProductId: id, pddProductId: 200 + id, eligible: stage(id) === 'purchase_ready', collectionEligible: false, aiMatchEligible: stage(id) === 'color_mapping', aiMatchDisabledReason: stage(id) === 'color_mapping' ? '' : '已有有效确认映射,无需 AI 匹配', processStage: stage(id), processStageLabel: stage(id) === 'purchase_ready' ? '可创建采购' : '颜色待匹配', processStageReason: '' }))
|
||||
const items = [81, 82, 83].map(id => ({ sybProductId: id, pddProductId: 200 + id, eligible: stage(id) === 'purchase_ready', collectionEligible: false, aiMatchEligible: stage(id) === 'color_mapping', aiMatchDisabledReason: stage(id) === 'color_mapping' ? '' : '已有有效确认映射,无需 AI 匹配', processStage: stage(id), processStageLabel: stage(id) === 'purchase_ready' ? '可创建采购' : '规格待匹配', processStageReason: '' }))
|
||||
return route.fulfill({ json: { code: 200, data: { items, eligibleCount: items.filter(item => item.eligible).length, skippedCount: items.filter(item => !item.eligible).length }}})
|
||||
}
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
@@ -179,7 +179,7 @@ test('未关联 PDD 显示去关联并直接打开 PDD 商品选择器', async({
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 41, orderCode: 'SYB-LINK-41', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '未关联商品', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }, { id: 42, orderCode: 'SYB-MAP-42', shopeeItemId: '26154802795', shopeeProductId: 10, productTitle: '待匹配商品', shopName: '大碼臻選', targetColor: '白色', targetSize: 'L', quantity: 1, unitPriceCent: 1200, parseStatus: 'success' }], total: 2, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 41, eligible: false, processStage: 'pdd_unlinked', processStageLabel: '未关联 PDD', processStageReason: '请先关联 PDD 商品', processNextAction: 'open_pdd_link' }, { sybProductId: 42, eligible: false, processStage: 'color_mapping', processStageLabel: '颜色待匹配', processStageReason: '颜色匹配无效,请重新选择', processNextAction: 'open_mapping' }], eligibleCount: 0, skippedCount: 2 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 41, eligible: false, processStage: 'pdd_unlinked', processStageLabel: '未关联 PDD', processStageReason: '请先关联 PDD 商品', processNextAction: 'open_pdd_link' }, { sybProductId: 42, eligible: false, processStage: 'color_mapping', processStageLabel: '规格待匹配', processStageReason: '规格匹配无效,请重新选择', processNextAction: 'open_mapping' }], eligibleCount: 0, skippedCount: 2 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: '26154802794', title: '未关联商品', shopName: '大碼臻選', pddProductId: null, specs: [] } } }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
|
||||
Reference in New Issue
Block a user