diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index e960277..7570a73 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: c326132fdfa8407abc7cc7643073291be359802b -synchronized_at: 2026-09-03T01:30:19Z +wiki_revision: 9b195f3c578f980b808e6eb28f36e7bf5294e75b +synchronized_at: 2026-09-03T01:55:28Z # 业务规则与术语 @@ -31,7 +31,7 @@ synchronized_at: 2026-09-03T01:30:19Z - 关联 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 匹配与 #194 的 Admin 蝦皮商品详情一键匹配是两个独立例外。#188 的进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;SYB 商品页批量 `ai_match` 只要 Provider 成功返回规格结果且理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合,即可直接持久化为 `confirmed`,置信度仅记录供审计、不作为放行门槛。无结果、无组合证据、无效组合或 Provider 异常不得改写现有映射。 +- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配与 #194 的 Admin 蝦皮商品详情一键匹配是两个独立例外。#188 的进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;SYB 商品页批量 `ai_match` 只要 Provider 成功返回规格结果且理由非空、返回值属于当前可选候选即可直接持久化为 `confirmed`,置信度仅记录供审计、不作为放行门槛。若当前 PDD 档案存在完整可售颜色+尺码 SKU 组合证据,结果还必须命中其中同一个组合;人工录入或外部导入导致组合证据缺失时不阻断保存或创建采购。无结果、已有组合证据中的无效组合或 Provider 异常不得改写现有映射。 - 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。 - Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。 - PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。 diff --git a/server/app/goauto/purchase/ai_match_eligibility.go b/server/app/goauto/purchase/ai_match_eligibility.go index a3ef3d3..04fbb3b 100644 --- a/server/app/goauto/purchase/ai_match_eligibility.go +++ b/server/app/goauto/purchase/ai_match_eligibility.go @@ -140,7 +140,10 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa if request.TargetSize != "" { size = matched.MappedSize } - if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) { + // 人工录入或外部导入的 PDD 档案可以没有采集任务 SKU 证据。没有 + // 证据时只确认候选值;一旦有证据,仍必须命中同一个可售组合。 + combinations := dataset.skuCombinationsByPDD[pdd.ID] + if len(combinations) == 0 || validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, color, size) { return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize, Deterministic: &matched} } } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index f5d7370..1c70f98 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -479,8 +479,9 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat // 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. +// still identify one complete, available SKU combination when collection +// evidence exists. Manually entered or externally imported PDD products may +// legitimately have no such collection record. func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) { if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired { return @@ -505,12 +506,7 @@ func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPrev 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) { + if len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { item.Eligible = false item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping" } diff --git a/server/app/goauto/purchase/batch_spec_match.go b/server/app/goauto/purchase/batch_spec_match.go index 3207660..737a876 100644 --- a/server/app/goauto/purchase/batch_spec_match.go +++ b/server/app/goauto/purchase/batch_spec_match.go @@ -101,7 +101,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) if request.TargetSize != "" { mappedSize = matched.MappedSize } - if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || !validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { + combinations := dataset.skuCombinationsByPDD[pdd.ID] + if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || (len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize)) { item.Status, item.Reason = BatchSpecMatchPending, "AI 结果不是当前可售的 PDD 规格组合,请人工确认" response.PendingCount++ response.Items = append(response.Items, item) diff --git a/server/app/goauto/purchase/batch_spec_match_test.go b/server/app/goauto/purchase/batch_spec_match_test.go index f6da230..bcd608e 100644 --- a/server/app/goauto/purchase/batch_spec_match_test.go +++ b/server/app/goauto/purchase/batch_spec_match_test.go @@ -265,6 +265,23 @@ func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T) } } +func TestBatchSpecMatchAndPreviewAllowMissingSKUCombinationEvidence(t *testing.T) { + service, f := unresolvedBatchSpecFixture(t) + if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil { + t.Fatal(err) + } + service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}} + + matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil || matched.AutoConfirmedCount != 1 || matched.PendingCount != 0 { + t.Fatalf("AI result without SKU evidence was not confirmed: %+v err=%v", matched, err) + } + preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady { + t.Fatalf("missing SKU evidence unexpectedly blocked purchase: %+v err=%v", preview, err) + } +} + func TestBatchSpecMatchAutoConfirmsReturnedAIMatchRegardlessOfConfidence(t *testing.T) { service, f := unresolvedBatchSpecFixture(t) service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}