Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
041cd8d03f |
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: de7e400503d6e7b9c335657114893de345bfead5
|
||||
synchronized_at: 2026-08-31T03:33:36Z
|
||||
wiki_revision: b94fd07dfdebe418257a675a779f83b782896543
|
||||
synchronized_at: 2026-09-01T14:15:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -56,7 +56,7 @@ Android Portal/Agent
|
||||
- Android 本地互斥与服务端原子领取共同保证单设备串行。
|
||||
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
|
||||
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
|
||||
## 最小业务数据
|
||||
|
||||
|
||||
@@ -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: b94fd07dfdebe418257a675a779f83b782896543
|
||||
synchronized_at: 2026-09-01T14:15:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
|
||||
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
|
||||
|
||||
|
||||
@@ -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: c214791a0b055e5b56c0ad05e73829a1f141dd46
|
||||
synchronized_at: 2026-09-01T08:03:51Z
|
||||
wiki_revision: b94fd07dfdebe418257a675a779f83b782896543
|
||||
synchronized_at: 2026-09-01T14:16:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -49,15 +49,18 @@ DELETE /api/admin/v1/shopee-products/{productId}/specs/mapping
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm-exact-matches
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/preview-auto-size
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/auto-match
|
||||
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 仅通过下述采购批量规格匹配接口的独立写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `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 或 #194 改变;两者只能通过各自独立的服务端写入路径,将唯一确定性 `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 的显式“保存修改”仍通过既有设置/确认接口落库。
|
||||
|
||||
`auto-match` 是 Admin 蝦皮商品详情一键匹配颜色和尺码的独立写入接口。请求体必须提交 UUID `requestId` 和详情返回的 `specContextVersion`。服务端在事务外完成必要的 Provider 调用,入事务后重新锁定蝦皮商品与 PDD 商品,复核关联、完整规格上下文、当前可选候选、AI 开关和最新置信度阈值。保留有效的已确认映射;唯一确定结果直接保存为 `exact_match + confirmed`;置信度达标、理由非空且候选仍有效的 AI 结果保存为 `ai_match + confirmed`;其余项保持未匹配。响应含 `product`、`items[]`(`dimension`、`role`、`valueName`、可选 `pddValue`、`status=confirmed|preserved|unmatched`、可选 `source`、`confidence`、`reason`)、`confirmedCount`、`preservedCount`、`unmatchedCount` 和 `replayed`。同一 `requestId` 幂等重放;Provider 异常或 AI 未启用返回 `AI_MATCHING_UNAVAILABLE`,关联或规格变化返回 `SPEC_CONTEXT_VERSION_STALE`,两者都不写入本次结果。
|
||||
|
||||
设置映射时,`pddValue` 必须是关联 PDD 商品同角色下当前可选的原始规格标签,否则返回 `INVALID_REQUEST`。PDD 重新采集后旧目标消失时,Admin 标记失效;采购预检与任务创建返回 `PURCHASE_SPEC_MAPPING_REQUIRED` 和“规格匹配已失效,请重新选择 PDD 规格”,不得把旧标签下发给 Agent。
|
||||
|
||||
`batch-delete` 提交 `requestId` 和 `ids`(1~500 个),逐条校验引用后返回每条的 `status`(`deleted` / `skipped`)与 `reason`;引用检查覆盖 SYB 明细(#41)与采购任务(#33/#34),两张表落地前恒不阻塞删除。`restore` 恢复一条已软删除商品,恢复后原有 PDD 关联与规格映射保持不变。列表接口 `status=deleted` 筛选已删除商品,默认只返回存活商品。
|
||||
|
||||
@@ -47,6 +47,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true},
|
||||
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
|
||||
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
|
||||
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
|
||||
|
||||
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
|
||||
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
|
||||
|
||||
@@ -62,3 +62,12 @@ func TestPurchaserMayRunBatchSpecMatch(t *testing.T) {
|
||||
}
|
||||
t.Fatal("missing purchaser batch spec match permission")
|
||||
}
|
||||
|
||||
func TestPurchaserMayAutoMatchShopeeProductSpecs(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser shopee auto-match permission")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AutoMatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
}
|
||||
|
||||
type AutoMatchItem struct {
|
||||
Dimension string `json:"dimension"`
|
||||
Role string `json:"role"`
|
||||
ValueName string `json:"valueName"`
|
||||
PDDValue string `json:"pddValue,omitempty"`
|
||||
Status string `json:"status"` // confirmed | preserved | unmatched
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type AutoMatchResponse struct {
|
||||
Product ProductView `json:"product"`
|
||||
Items []AutoMatchItem `json:"items"`
|
||||
ConfirmedCount int `json:"confirmedCount"`
|
||||
PreservedCount int `json:"preservedCount"`
|
||||
UnmatchedCount int `json:"unmatchedCount"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
type autoMatchRef struct {
|
||||
dimension string
|
||||
valueName string
|
||||
}
|
||||
|
||||
// AutoMatchMappings resolves both color and size values, then persists every
|
||||
// accepted result as confirmed in one transaction. Provider calls happen
|
||||
// before the transaction; the transaction rechecks the complete spec context
|
||||
// and PDD candidate set so a stale decision can never be written.
|
||||
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
if _, err := uuid.Parse(requestID); err != nil {
|
||||
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
requestVersion := strings.TrimSpace(request.SpecContextVersion)
|
||||
if requestVersion == "" {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
|
||||
db := service.DB.WithContext(ctx)
|
||||
var replay models.ShopeeProduct
|
||||
if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, productNotFound()
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "请先关联 PDD 商品"}
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := db.First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"}
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) != requestVersion {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
initialSpecs, err := Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
items := make([]AutoMatchItem, 0)
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
refs := autoMatchRefs(initialSpecs, role)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
preview, err := service.suggestMappings(ctx, id, requestVersion, role)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if len(preview.Items) != len(refs) {
|
||||
return AutoMatchResponse{}, internalError(errors.New("AI 匹配结果与规格输入数量不一致"))
|
||||
}
|
||||
for index, suggestion := range preview.Items {
|
||||
item := AutoMatchItem{
|
||||
Dimension: refs[index].dimension,
|
||||
Role: role,
|
||||
ValueName: refs[index].valueName,
|
||||
PDDValue: suggestion.PDDValue,
|
||||
Confidence: suggestion.Confidence,
|
||||
Reason: strings.TrimSpace(suggestion.Reason),
|
||||
Status: "unmatched",
|
||||
}
|
||||
switch suggestion.Status {
|
||||
case "preserved":
|
||||
item.Status = "preserved"
|
||||
case "matched":
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceExactMatch
|
||||
case "ai_matched":
|
||||
if suggestion.Confidence != nil && item.Reason != "" {
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceAIMatch
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
replayed := false
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return productNotFound()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if current.LastUpdateRequestID != nil && *current.LastUpdateRequestID == requestID {
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if current.PDDProductID == nil || *current.PDDProductID != pdd.ID {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var currentPDD models.PDDProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤tPDD, pdd.ID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return contextVersionStale()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(current.PDDProductID, current.SpecsJSON, currentPDD.SpecsJSON) != requestVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
|
||||
currentSpecs, err := Unmarshal(current.SpecsJSON)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates := map[string]map[string]bool{}
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
values, err := selectablePDDValues(currentPDD.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates[role] = values
|
||||
}
|
||||
|
||||
needsAISettings := false
|
||||
for _, item := range items {
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch {
|
||||
needsAISettings = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsAISettings {
|
||||
settings, err := aimatching.NewService(tx).Settings(ctx)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
|
||||
}
|
||||
for index := range items {
|
||||
item := &items[index]
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch &&
|
||||
(item.Confidence == nil || *item.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(item.Reason) == "") {
|
||||
item.Status, item.Source = "unmatched", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plans := make(map[string]AutoMatchItem, len(items))
|
||||
for _, item := range items {
|
||||
plans[item.Dimension+"\x00"+item.ValueName] = item
|
||||
}
|
||||
for dimensionIndex, dimension := range currentSpecs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for valueIndex, value := range dimension.Values {
|
||||
item, ok := plans[dimension.Name+"\x00"+value.Name]
|
||||
if !ok || item.Status != "confirmed" {
|
||||
continue
|
||||
}
|
||||
if !candidates[dimension.Role][item.PDDValue] {
|
||||
return contextVersionStale()
|
||||
}
|
||||
currentSpecs[dimensionIndex].Values[valueIndex].Mapping = &Mapping{
|
||||
PDDValue: item.PDDValue, Source: item.Source, Status: MappingStatusConfirmed,
|
||||
Confidence: item.Confidence, Reason: item.Reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := Validate(currentSpecs); err != nil {
|
||||
return invalidRequest(err.Error())
|
||||
}
|
||||
specsJSON, err := Marshal(currentSpecs)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"specs_json": specsJSON, "last_update_request_id": requestID,
|
||||
}).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := syncReplacementItemAfterManualEdit(tx, id, currentSpecs); err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if replayed {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
}
|
||||
response, err := service.autoMatchResponse(ctx, id, items)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
|
||||
refs := make([]autoMatchRef, 0)
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
refs = append(refs, autoMatchRef{dimension: dimension.Name, valueName: value.Name})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchResponse(ctx context.Context, id uint64, items []AutoMatchItem) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: items}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "confirmed":
|
||||
response.ConfirmedCount++
|
||||
case "preserved":
|
||||
response.PreservedCount++
|
||||
default:
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchReplay(ctx context.Context, id uint64) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: []AutoMatchItem{}, Replayed: true}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
item := AutoMatchItem{Dimension: dimension.Name, Role: dimension.Role, ValueName: value.Name, Status: "unmatched"}
|
||||
if value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed {
|
||||
item.PDDValue, item.Status, item.Source = value.Mapping.PDDValue, "preserved", value.Mapping.Source
|
||||
item.Confidence, item.Reason = value.Mapping.Confidence, value.Mapping.Reason
|
||||
response.PreservedCount++
|
||||
} else {
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchMappingsConfirmsColorAndSizeInOneRequest(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"颜色含义唯一一致"}]}`))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-BOTH", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("auto match: %v", err)
|
||||
}
|
||||
if response.ConfirmedCount != 2 || response.PreservedCount != 0 || response.UnmatchedCount != 0 {
|
||||
t.Fatalf("unexpected counts: %+v", response)
|
||||
}
|
||||
color := response.Product.Specs[0].Values[0].Mapping
|
||||
size := response.Product.Specs[1].Values[0].Mapping
|
||||
if color == nil || color.Status != MappingStatusConfirmed || color.Source != MappingSourceAIMatch || color.PDDValue != "黑色" {
|
||||
t.Fatalf("color not auto-confirmed: %+v", color)
|
||||
}
|
||||
if size == nil || size.Status != MappingStatusConfirmed || size.Source != MappingSourceExactMatch || size.PDDValue != "XL" {
|
||||
t.Fatalf("size not auto-confirmed: %+v", size)
|
||||
}
|
||||
|
||||
replayed, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil || !replayed.Replayed || replayed.PreservedCount != 2 {
|
||||
t.Fatalf("idempotent replay failed: response=%+v err=%v", replayed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsLeavesLowConfidenceUnmatched(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.4,"reason":"只能判断为相近颜色"}]}`))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-LOW", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ConfirmedCount != 1 || response.UnmatchedCount != 1 || response.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("low confidence color was persisted: %+v", response)
|
||||
}
|
||||
if response.Product.Specs[1].Values[0].Mapping == nil || response.Product.Specs[1].Values[0].Mapping.Status != MappingStatusConfirmed {
|
||||
t.Fatalf("deterministic size was not confirmed: %+v", response.Product.Specs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusBadGateway)
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-FAIL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeAIUnavailable {
|
||||
t.Fatalf("expected AI unavailable, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("provider failure changed mappings: detail=%+v err=%v", detail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
call := calls.Add(1)
|
||||
if call == 2 {
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true},{"name":"灰色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"content": `{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.98,"reason":"唯一可靠候选"}]}`}}}})
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-STALE", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "小号", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeSpecContextStale {
|
||||
t.Fatalf("expected stale context, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil {
|
||||
t.Fatalf("context drift caused partial write: %+v", detail.Product.Specs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,28 @@ func (handler Handler) SuggestSizeMappings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AutoMatchMappings(c *gin.Context) {
|
||||
id, ok := handler.pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request AutoMatchRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.AutoMatchMappings(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) BatchDelete(c *gin.Context) {
|
||||
var request BatchDeleteRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
|
||||
@@ -26,4 +26,5 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/:productId/specs/mapping/preview-auto-size", handler.PreviewAutoSizeMatches)
|
||||
admin.POST("/:productId/specs/mapping/suggest-colors", handler.SuggestColorMappings)
|
||||
admin.POST("/:productId/specs/mapping/suggest-sizes", handler.SuggestSizeMappings)
|
||||
admin.POST("/:productId/specs/mapping/auto-match", handler.AutoMatchMappings)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export function suggestShopeeSizeMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function autoMatchShopeeSpecMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function batchDeleteShopeeProducts(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
|
||||
}
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
</el-descriptions>
|
||||
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
|
||||
|
||||
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
|
||||
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<div class="section-heading"><h3>规格匹配</h3><div class="mapping-heading-actions"><span class="mapping-progress">{{ mappingProgressText }}</span><el-button type="primary" :loading="detail.aiMatching" :disabled="!canAutoMatch" :title="hasMappingChanges ? '请先保存或撤销当前人工修改' : ''" @click="autoMatchMappings">一键匹配颜色和尺码</el-button></div></div>
|
||||
<el-alert title="成功匹配会直接保存并可用于采购;未通过置信度或候选校验的项目保持未匹配。不会修改蝦皮或 PDD 的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -39,7 +38,6 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -79,7 +77,7 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue,
|
||||
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings
|
||||
autoMatchShopeeSpecMappings
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
@@ -107,8 +105,7 @@ export default {
|
||||
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
|
||||
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
|
||||
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
|
||||
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
|
||||
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 }
|
||||
canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
|
||||
},
|
||||
watch: {
|
||||
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
|
||||
@@ -117,7 +114,7 @@ export default {
|
||||
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
|
||||
},
|
||||
methods: {
|
||||
emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
|
||||
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
|
||||
@@ -164,7 +161,7 @@ export default {
|
||||
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
|
||||
async previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = false } },
|
||||
async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } },
|
||||
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
|
||||
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
|
||||
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
|
||||
@@ -175,5 +172,5 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.tab-actions{align-items:flex-start;flex-direction:column}}
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-heading-actions{display:flex;align-items:center;gap:12px}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.section-heading,.mapping-heading-actions{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -51,22 +51,26 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa
|
||||
expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active')
|
||||
})
|
||||
|
||||
test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => {
|
||||
test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD 抽屉', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let autoMatched = false
|
||||
let autoMatchBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
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/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) {
|
||||
return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/auto-match')) {
|
||||
autoMatchBody = route.request().postDataJSON()
|
||||
autoMatched = true
|
||||
return route.fulfill({ json: { code: 200, data: { confirmedCount: 2, preservedCount: 0, unmatchedCount: 0, items: [] }}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) {
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }}}})
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
@@ -75,15 +79,19 @@ test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', as
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'AI 匹配', exact: true }).click()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('')
|
||||
await expect(page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'AI 匹配', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('可采购', { exact: true })).toBeVisible()
|
||||
expect(autoMatchBody?.specContextVersion).toBe('context-3')
|
||||
expect(String(autoMatchBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
|
||||
await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user