diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 896989e..e960277 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: a6f63cc745cf0e0df7ca94521a70fb6af3adea26 -synchronized_at: 2026-09-01T09:55:26Z +wiki_revision: c326132fdfa8407abc7cc7643073291be359802b +synchronized_at: 2026-09-03T01:30:19Z # 业务规则与术语 @@ -31,9 +31,9 @@ synchronized_at: 2026-09-01T09:55:26Z - 关联 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 匹配是独立例外,其进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `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`,置信度仅记录供审计、不作为放行门槛。无结果、无组合证据、无效组合或 Provider 异常不得改写现有映射。 - 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。 -- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。 +- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。 - PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。 - 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。 @@ -393,3 +393,19 @@ synchronized_at: 2026-09-01T09:55:26Z - 规格映射摘要使用 `specMappingStatus=complete|incomplete|not_required`、确认数和总数。`not_required` 只表示虾皮商品没有颜色或尺码维度;商品摘要不替代订单行采购资格。 - “关联订单继续采购”只处理已有 SYB 订单;“创建备货采购”不关联 SYB 订单。两者入口和文案必须明确区分。继续采购复用既有批量预检和批量创建,价格、映射、并发、订单和不可逆门禁不变。 - SYB 订单号、目标颜色/尺码、数量和当前采购任务状态只允许管理员与采购员读取;其他角色由服务端拒绝。查询路径不调用 AI Provider。 + +## 定时与手动蝦皮规格自动匹配(#195) + +- 系统只扫描存活且已关联 `active` PDD 商品、两边至少共享颜色或尺码角色、并且至少存在一个未确认或已失效映射的蝦皮商品。 +- 每个商品复用详情页“一键匹配颜色和尺码”的规则:保留当前仍有效的 `confirmed` 映射;唯一确定匹配和达到服务端阈值、理由非空、候选仍有效的 AI 结果直接保存为 `confirmed`;低置信度、无结果、Provider 异常或上下文漂移不猜测、不写入错误映射。 +- 定时与管理员手动执行共用全局活动槽和逐商品工作状态。单批默认最多 20 个商品;同一规格上下文与 AI 设置更新时间未变化时,已完成、低置信度或无结果商品不重复调用 AI。 +- Provider 临时失败最多尝试 3 次,间隔至少 60 分钟;输入变化后重新计算指纹并允许重新处理。运行记录只保存结构化计数和脱敏限长错误,不保存 API Key、Provider 原始响应、商品原始 JSON 或个人数据。 +- 系统定时任务迁移后默认关闭,须由管理员明确启用。该能力仅维护蝦皮与 PDD 颜色/尺码映射,不创建采购任务、PDD 订单,不触发 Agent,也不执行付款。 + +## SYB 异常采购规格 AI 解析(#198) + +- SYB 采购规格采用两阶段流程:先把货运单明细 `productSpec` 解析为目标颜色/尺码,再由 #195 把蝦皮规格匹配到 PDD 规格;两阶段不得混为同一匹配事实。 +- 定时任务只处理未人工确认且确定性解析为 `uncertain` / `failed` 的明细。每条先重跑确定性解析;空 `productSpec`、无关联蝦皮商品、无颜色/尺码候选或同一角色存在多个候选维度时不调用 AI,继续人工处理。 +- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。 +- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。 +- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。 diff --git a/server/app/goauto/purchase/batch_spec_match.go b/server/app/goauto/purchase/batch_spec_match.go index b310510..3207660 100644 --- a/server/app/goauto/purchase/batch_spec_match.go +++ b/server/app/goauto/purchase/batch_spec_match.go @@ -50,11 +50,6 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) if err != nil { return BatchSpecMatchResponse{}, internal(err) } - settings, err := aimatching.NewService(s.DB).Settings(ctx) - if err != nil { - return BatchSpecMatchResponse{}, internal(err) - } - response := BatchSpecMatchResponse{Items: make([]BatchSpecMatchItem, 0, len(ids))} for _, id := range ids { item := BatchSpecMatchItem{SYBProductID: id, Status: BatchSpecMatchFailed} @@ -87,9 +82,11 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) } } item.Source, item.Confidence = matched.Source, matched.Decision.Confidence - autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "") + // #200:在 SYB 批量入口,AI 只要返回了可保存的规格结果,就由后续的 + // 候选与可售 SKU 组合校验决定是否放行;置信度仅保留为审计信息。 + autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && strings.TrimSpace(matched.Decision.Reason) != "") if !autoConfirm { - item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认" + item.Status, item.Reason = BatchSpecMatchPending, "AI 未返回可用规格结果,请人工确认" if strings.TrimSpace(matched.Decision.Reason) != "" { item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason) } @@ -117,7 +114,7 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) response.Items = append(response.Items, item) continue } - if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil { + if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), writes); err != nil { item.Reason = "规格映射保存失败,请刷新后重试" response.FailedCount++ 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 162c314..f6da230 100644 --- a/server/app/goauto/purchase/batch_spec_match_test.go +++ b/server/app/goauto/purchase/batch_spec_match_test.go @@ -265,21 +265,21 @@ func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T) } } -func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(t *testing.T) { +func TestBatchSpecMatchAutoConfirmsReturnedAIMatchRegardlessOfConfidence(t *testing.T) { service, f := unresolvedBatchSpecFixture(t) service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}} response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}}) - if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 { - t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err) + if err != nil || response.PendingCount != 0 || response.AutoConfirmedCount != 1 { + t.Fatalf("low-confidence AI result was not auto-confirmed: %+v err=%v", response, err) } mapping := savedColorMapping(t, service, f.shopee.ID) - if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed { - t.Fatalf("low-confidence result changed the saved mapping: %+v", mapping) + if mapping == nil || mapping.PDDValue != "米白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Confidence == nil || *mapping.Confidence != 0.6 { + t.Fatalf("AI result was not saved as confirmed: %+v", mapping) } preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) - if err != nil || preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStageColorMapping { - t.Fatalf("pending mapping unexpectedly became purchase-ready: %+v err=%v", preview, err) + if err != nil || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady { + t.Fatalf("saved AI result did not become purchase-ready: %+v err=%v", preview, err) } } diff --git a/server/app/goauto/shopeeproduct/batch_resolved_mapping.go b/server/app/goauto/shopeeproduct/batch_resolved_mapping.go index fe70d12..2a4502d 100644 --- a/server/app/goauto/shopeeproduct/batch_resolved_mapping.go +++ b/server/app/goauto/shopeeproduct/batch_resolved_mapping.go @@ -7,7 +7,8 @@ import ( // ResolvedMappingItem is one mapping produced by the narrowly scoped SYB // batch-match entry point. Confirmed writes are restricted to auditable exact -// matches and high-confidence AI decisions; SetMapping remains pending-first. +// matches and AI decisions with a returned match reason; SetMapping remains +// pending-first. type ResolvedMappingItem struct { Dimension string ValueName string @@ -20,14 +21,11 @@ type ResolvedMappingItem struct { // ApplyResolvedMappings atomically applies the color/size mappings needed by // one SYB detail row. It is independent from SetMapping so #188's explicit -// high-confidence exception cannot change existing callers' pending semantics. -func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, minimumConfidence float64, items []ResolvedMappingItem) (SaveResponse, error) { +// confirmed-match exception cannot change existing callers' pending semantics. +func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, items []ResolvedMappingItem) (SaveResponse, error) { if len(items) == 0 || len(items) > 2 { return SaveResponse{}, invalidRequest("必须包含 1 至 2 个待写入规格映射") } - if minimumConfidence < 0 || minimumConfidence > 1 { - return SaveResponse{}, invalidRequest("自动确认阈值无效") - } seen := make(map[string]bool, len(items)) for _, item := range items { key := strings.TrimSpace(item.Dimension) + "\x00" + strings.TrimSpace(item.ValueName) @@ -41,9 +39,6 @@ func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, re 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 }