fix: trust manually confirmed SYB specs (#188)

This commit is contained in:
QiuSW
2026-09-01 16:04:35 +08:00
parent 776db137be
commit 4a1b4af29d
5 changed files with 36 additions and 8 deletions
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 687439543630832db26d2c73f6ada980e34eea10 wiki_revision: 3e4f463cfa026a09ba125f2eb376bd42ef41c880
synchronized_at: 2026-09-01T07:28:03Z synchronized_at: 2026-09-01T08:03:37Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
# 业务规则与术语 # 业务规则与术语
@@ -31,7 +31,7 @@ synchronized_at: 2026-09-01T07:28:03Z
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。 - 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。 - 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。 - 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
- 映射来源分 `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 异常不得改写现有映射。 - 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量匹配;未人工确认的 `parse_status=uncertain` 必须先人工修正;`manually_confirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。 - 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。 - 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。 - PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: b3d7656c10bb3ec93894f69dbe078c5aa3037ef7 wiki_revision: c214791a0b055e5b56c0ad05e73829a1f141dd46
synchronized_at: 2026-09-01T07:28:16Z synchronized_at: 2026-09-01T08:03:51Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约 # MVP 共享 API 契约
@@ -502,7 +502,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 | | `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/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` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 | | `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-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;未人工确认的 `parse_status=uncertain` 不进入自动确认,`manuallyConfirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `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/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 |
@@ -33,6 +33,10 @@ type skuCombinationRow struct {
Value string `gorm:"column:value"` Value string `gorm:"column:value"`
} }
func sybSpecsTrusted(syb models.SYBProduct) bool {
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed
}
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error { func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
if len(pddIDs) == 0 { if len(pddIDs) == 0 {
return nil return nil
@@ -92,7 +96,7 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
if !found { if !found {
return disabled("SYB 商品不存在或已删除") return disabled("SYB 商品不存在或已删除")
} }
if syb.ParseStatus != models.SYBParseStatusSuccess { if !sybSpecsTrusted(syb) {
if syb.ParseStatus == models.SYBParseStatusUncertain { if syb.ParseStatus == models.SYBParseStatusUncertain {
return disabled("采购规格解析存疑,请先人工修正并确认") return disabled("采购规格解析存疑,请先人工修正并确认")
} }
+1 -1
View File
@@ -388,7 +388,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
} }
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
if syb.ParseStatus != models.SYBParseStatusSuccess { if !sybSpecsTrusted(syb) {
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse" item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
if syb.ParseStatus == models.SYBParseStatusUncertain { if syb.ParseStatus == models.SYBParseStatusUncertain {
item.Reason = "采购规格解析存疑,请先人工修正并确认" item.Reason = "采购规格解析存疑,请先人工修正并确认"
@@ -175,6 +175,30 @@ func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
} }
} }
func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
service, f := exactBatchSpecFixture(t)
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": true,
}).Error; err != nil {
t.Fatal(err)
}
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("manually confirmed specs did not enter matching: %+v err=%v", before, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
t.Fatalf("manual correction did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
}
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("saved mapping did not unlock manual correction: %+v err=%v", after, err)
}
}
func TestBatchSpecMatchAutoConfirmsHighConfidenceAndRefreshesReadiness(t *testing.T) { func TestBatchSpecMatchAutoConfirmsHighConfidenceAndRefreshesReadiness(t *testing.T) {
service, f := unresolvedBatchSpecFixture(t) service, f := unresolvedBatchSpecFixture(t)
duplicate := f.syb duplicate := f.syb