Compare commits

..
Author SHA1 Message Date
QiuSW 9eb28c225c fix(android): reconcile folded order details (#192) 2026-09-01 17:07:51 +08:00
7 changed files with 89 additions and 37 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 52
versionName = "0.9.39"
versionCode = 51
versionName = "0.9.38"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+4 -4
View File
@@ -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: 3e4f463cfa026a09ba125f2eb376bd42ef41c880
synchronized_at: 2026-09-01T08:03:37Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -31,7 +31,7 @@ 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 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量匹配;未人工确认的 `parse_status=uncertain` 必须先人工修正;`manually_confirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
@@ -150,7 +150,7 @@ synchronized_at: 2026-09-01T09:55:26Z
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
@@ -2,11 +2,13 @@ package purchase
import (
"context"
"encoding/json"
"fmt"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/productspec"
"go-admin/app/goauto/shopeeproduct"
)
@@ -94,6 +96,12 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
if !found {
return disabled("SYB 商品不存在或已删除")
}
if !sybSpecsTrusted(syb) {
if syb.ParseStatus == models.SYBParseStatusUncertain {
return disabled("采购规格解析存疑,请先人工修正并确认")
}
return disabled("采购规格解析失败,请先重新解析或人工修正")
}
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
return disabled("未解析出需要采购的颜色或尺码")
}
@@ -111,6 +119,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
if !found || pdd.Status != "active" {
return disabled("关联的 PDD 商品尚未采集完成或已停用")
}
if hasSelectableOtherDimension(pdd.SpecsJSON) {
return disabled("PDD 商品包含颜色、尺码之外的可选规格,请人工处理")
}
candidates, usable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
if !usable {
return disabled("关联的 PDD 商品没有完整可选规格")
@@ -128,9 +139,18 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
if mappedColor == "" {
request.TargetColor = syb.TargetColor
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleColor, syb.TargetColor) {
return disabled("蝦皮商品中未找到目标颜色,请先修正商品规格")
}
}
if mappedSize == "" {
request.TargetSize = syb.TargetSize
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleSize, syb.TargetSize) {
return disabled("蝦皮商品中未找到目标尺码,请先修正商品规格")
}
}
if len(dataset.skuCombinationsByPDD[pdd.ID]) == 0 {
return disabled("缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集")
}
if matched, ok := aimatching.DeterministicMatch(request); ok {
color, size := mappedColor, mappedSize
@@ -161,3 +181,42 @@ func validSKUCombination(combinations []pddSKUCombination, targetColor, targetSi
}
return false
}
func shopeeHasTarget(raw, role, target string) bool {
if strings.TrimSpace(target) == "" {
return true
}
specs, err := shopeeproduct.Unmarshal(raw)
if err != nil {
return false
}
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == target {
return true
}
}
}
return false
}
func hasSelectableOtherDimension(raw string) bool {
var dimensions []productspec.Dimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
return false
}
for _, dimension := range dimensions {
if dimension.Role != "other" {
continue
}
for _, value := range dimension.Values {
if value.Selectable {
return true
}
}
}
return false
}
+5 -4
View File
@@ -424,10 +424,9 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
mappedColor, mappedSize, source = "", "", "unresolved"
item.MappedColor, item.MappedSize = "", ""
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格匹配已失效,请重新选择 PDD 规格", "open_mapping"
return item
}
if source == "unresolved" {
if !archiveUsable {
@@ -444,8 +443,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
} else {
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格映射不完整,需要 AI 匹配或人工确认", "open_mapping"
return item
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
}
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
if err != nil {
@@ -241,16 +241,14 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
}
}
// #190 放开了 AI 匹配的解析状态与可售 SKU 组合前置:两者都不再阻断匹配,
// 采购侧自身的解析门禁(previewFromDataset)不在本次放开范围内,仍然生效。
func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T) {
func TestBatchPreviewRejectsUncertainParseAndMissingSKUCombination(t *testing.T) {
service, f := unresolvedBatchSpecFixture(t)
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusUncertain).Error; err != nil {
t.Fatal(err)
}
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
t.Fatalf("uncertain parse still blocked AI matching: %+v err=%v", preview, err)
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "采购规格解析存疑,请先人工修正并确认" || preview.Items[0].ProcessStage != ProcessStageManualAction {
t.Fatalf("uncertain parse entered AI matching: %+v err=%v", preview, err)
}
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusSuccess).Error; err != nil {
@@ -260,8 +258,8 @@ func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T)
t.Fatal(err)
}
preview, err = service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
t.Fatalf("missing SKU combination still blocked AI matching: %+v err=%v", preview, err)
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集" {
t.Fatalf("missing SKU combination entered AI matching: %+v err=%v", preview, err)
}
}
+10 -11
View File
@@ -353,24 +353,23 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
} else if pdd.Status != "active" {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
// #190:失效的映射不再拒绝,降级为 unresolved 交给规格探测。
if !mappingTargetsValid(candidates, targetColor, targetSize, mappedColor, mappedSize) {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else {
decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = string(decision)
return fail(CodeMappingRequired, "规格匹配已失效,请重新选择 PDD 规格")
}
decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = string(decision)
} else if !archiveUsable {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if externalPlan != nil {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched {
// #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else {
match, matched := aimatching.DeterministicMatch(matchRequest)
if !matched {
return fail(CodeMappingRequired, "规格映射不完整,需要异步规格匹配")
}
mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source
decision, marshalErr := json.Marshal(match.Decision)
if marshalErr != nil {
+4 -9
View File
@@ -176,20 +176,15 @@ func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) {
}
}
// #190 起失效的已确认映射不再拒绝创建,而是降级为 unresolved 并交由规格探测解析。
// 规则不支持 spec-probe 时仍然拒绝,那道门不在本次放开范围内。
func TestCreateLiveDowngradesExpiredConfirmedMappingToSpecProbe(t *testing.T) {
func TestCreateLiveRejectsExpiredConfirmedMapping(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
task, err := createLive(t, testService(db), f)
if err != nil {
t.Fatalf("expired mapping should downgrade, not reject: %v", err)
}
if task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("expired mapping was not downgraded: source=%q color=%q size=%q", task.SpecSource, task.MappedColorSnapshot, task.MappedSizeSnapshot)
_, err := createLive(t, testService(db), f)
if code(err) != CodeMappingRequired {
t.Fatalf("expected invalid mapping rejection, got %v", err)
}
}