diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index d5c3fef..1b38e42 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -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
# 业务规则与术语
@@ -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;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md
index b3dd9cd..23bf731 100644
--- a/docs/08-agent-api-contract.md
+++ b/docs/08-agent-api-contract.md
@@ -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
# 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`,管理员和采购员可操作 |
diff --git a/server/app/goauto/purchase/ai_match_eligibility.go b/server/app/goauto/purchase/ai_match_eligibility.go
index efb1517..6e5922d 100644
--- a/server/app/goauto/purchase/ai_match_eligibility.go
+++ b/server/app/goauto/purchase/ai_match_eligibility.go
@@ -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}
}
diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go
index d420cbd..4db668e 100644
--- a/server/app/goauto/purchase/batch.go
+++ b/server/app/goauto/purchase/batch.go
@@ -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 {
diff --git a/server/app/goauto/purchase/batch_spec_match.go b/server/app/goauto/purchase/batch_spec_match.go
index e198cab..b310510 100644
--- a/server/app/goauto/purchase/batch_spec_match.go
+++ b/server/app/goauto/purchase/batch_spec_match.go
@@ -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) != "" {
diff --git a/server/app/goauto/purchase/batch_spec_match_test.go b/server/app/goauto/purchase/batch_spec_match_test.go
index 9aa0a37..12ea72b 100644
--- a/server/app/goauto/purchase/batch_spec_match_test.go
+++ b/server/app/goauto/purchase/batch_spec_match_test.go
@@ -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
diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go
index deffa87..b074866 100644
--- a/server/app/goauto/purchase/batch_test.go
+++ b/server/app/goauto/purchase/batch_test.go
@@ -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"}})
}
diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go
index 4c43f0b..0415496 100644
--- a/server/app/goauto/purchase/process_stage.go
+++ b/server/app/goauto/purchase/process_stage.go
@@ -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 == "" {
diff --git a/server/app/goauto/purchase/process_stage_test.go b/server/app/goauto/purchase/process_stage_test.go
index 49604bc..608fe48 100644
--- a/server/app/goauto/purchase/process_stage_test.go
+++ b/server/app/goauto/purchase/process_stage_test.go
@@ -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
}},
diff --git a/server/app/goauto/shopeeproduct/batch_resolved_mapping.go b/server/app/goauto/shopeeproduct/batch_resolved_mapping.go
index 1fb7d08..fe70d12 100644
--- a/server/app/goauto/shopeeproduct/batch_resolved_mapping.go
+++ b/server/app/goauto/shopeeproduct/batch_resolved_mapping.go
@@ -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
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index 9448ec5..07c5287 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -14,7 +14,7 @@
查询重置AI 匹配{{ aiMatchCandidates.length }}创建采购{{ purchaseCandidates.length }}创建采集{{ collectionCandidates.length }}
-
+
无图
@@ -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
diff --git a/web/tests/e2e/pdd-related-products.spec.ts b/web/tests/e2e/pdd-related-products.spec.ts
index 509aef3..6387119 100644
--- a/web/tests/e2e/pdd-related-products.spec.ts
+++ b/web/tests/e2e/pdd-related-products.spec.ts
@@ -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 }}})
diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts
index ada83e5..095fb02 100644
--- a/web/tests/e2e/syb-product-layout.spec.ts
+++ b/web/tests/e2e/syb-product-layout.spec.ts
@@ -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: [] }})