Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cfd32babe | ||
|
|
3181302c16 | ||
|
|
2b01974fe5 |
@@ -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: 46a5f8234be2d55298779532058a0490a94d3167
|
||||
synchronized_at: 2026-09-10T03:36:58Z
|
||||
wiki_revision: 6c8866d6186d8d6cc022256f2f369cc3072e99b5
|
||||
synchronized_at: 2026-09-10T07:50:14Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -411,3 +411,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
|
||||
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
|
||||
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
|
||||
|
||||
## SYB 行内一键替换(#258)
|
||||
|
||||
实现绑定 `2b01974`;部署与验收见 #258 发布证据。本节补充 #253,不改变原未关联入口。
|
||||
|
||||
- `syb-products/quick-link.js` 复用分体按钮、设备偏好和匹配/回读流程;已关联且允许处理阶段时同位置显示一键替换。先只读预览,固定任务、目标商品及规格版本;不同商品一次确认,同商品不重绑。
|
||||
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
|
||||
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
|
||||
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
|
||||
|
||||
@@ -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: 31d2dbd24acd8ac119dfd0d538c12d0525a2764f
|
||||
synchronized_at: 2026-09-10T06:54:35Z
|
||||
wiki_revision: d016609062586a278560226600b5a1443c6fb442
|
||||
synchronized_at: 2026-09-10T07:50:17Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -560,3 +560,13 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
|
||||
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
|
||||
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
|
||||
|
||||
## SYB 已关联商品的一键替换(#258)
|
||||
|
||||
实现绑定 `2b01974`;部署与验收见 #258 发布证据。
|
||||
|
||||
- 复用一键关联及小箭头的位置,保留原查看 PDD 商品。PDD 待采集、采集失败、停用或缺价格的对应处理阶段可替换;采集中、已创建/运行采购、成功或结果未知不开放。服务端提交时再次检查,不能只相信按钮。
|
||||
- 复用每用户/浏览器/API环境的手机偏好;首次选择继续当前行,小箭头仅切换偏好。预览仅选所选手机最新 completed/completed_partial 临时采集,部分成功或不可用不回退旧商品。确认后固定来源任务和目标,不改用新采集;来源状态、设备、目标规格或旧关联改变时拒绝。
|
||||
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
|
||||
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
|
||||
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
|
||||
|
||||
@@ -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: 23160c055e8f6185a4108e284afc8216b41373e5
|
||||
synchronized_at: 2026-09-10T03:37:18Z
|
||||
wiki_revision: a96638c0b97eac622d53b04420da7ca35a1c010d
|
||||
synchronized_at: 2026-09-10T07:50:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -1093,3 +1093,26 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
|
||||
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
|
||||
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
|
||||
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
|
||||
|
||||
## Admin 一键替换最近采集候选(#258)
|
||||
|
||||
实现绑定 `2b01974`;仅补充 Admin,Android/API Key 不变。沿用 `POST /api/admin/v1/shopee-products/{productId}/link-pdd` 和原权限,不新增路由或迁移。
|
||||
|
||||
先请求只读预览(仍需 UUID requestId):
|
||||
|
||||
```json
|
||||
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"当前详情版本","replacement":{"preview":true,"expectedPddProductId":3}}}
|
||||
```
|
||||
|
||||
响应 `product` 保持原关联,`collectionSource` 返回 taskId、deviceId、pddProductId、requiresSpecConfirmation,并增加 targetVersion、title、goodsId、previousTitle、previousGoodsId、deviceName。预览没有关联写入。
|
||||
|
||||
确认提交沿用同一 SYB/设备/详情版本与原商品ID,replacement 改为:
|
||||
|
||||
```json
|
||||
{"preview":false,"expectedPddProductId":3,"taskId":4,"targetPddProductId":5,"targetVersion":"预览返回值"}
|
||||
```
|
||||
|
||||
- 提交按指定 taskId 核对临时采集来源、设备、completed/finishedAt、目标ID、可用规格及 targetVersion;不重新选择最新任务。旧关联、旧/目标规格版本变更返回现有409上下文冲突;阶段不再允许返回409 PDD_LINK_CONFLICT;来源不可用409 LATEST_COLLECTION_UNAVAILABLE;缺字段422。
|
||||
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
|
||||
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
|
||||
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
|
||||
|
||||
@@ -63,7 +63,7 @@ func InitRouter() {
|
||||
goautoproduct.InitRouter(r, authMiddleware)
|
||||
goautoreplacement.InitRouter(r, authMiddleware)
|
||||
goautorule.InitRouter(r, authMiddleware)
|
||||
goautoshopeeproduct.InitRouter(r, authMiddleware)
|
||||
goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement)
|
||||
goautosybimport.InitRouter(r, authMiddleware)
|
||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Called inside the link transaction, after locking the SYB and Shopee rows.
|
||||
// Reuse the authoritative stage calculation, without AI or task creation.
|
||||
func ValidateQuickReplacement(ctx context.Context, tx *gorm.DB, id uint64) error {
|
||||
s := NewService(tx)
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, _, err := s.readOnlyPriceGuard(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preview := s.previewFromDataset(id, dataset, guard)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
preview.AIMatchEligible = qualification.Eligible
|
||||
preview.AIMatchDisabledReason = qualification.DisabledReason
|
||||
stage := processStageFromDataset(id, dataset, preview)
|
||||
syb := dataset.sybByID[id]
|
||||
disabled := false
|
||||
if syb.ShopeeProductID != nil {
|
||||
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if shopee.PDDProductID != nil {
|
||||
pdd := dataset.pddByID[*shopee.PDDProductID]
|
||||
disabled = pdd.Status == "disabled"
|
||||
if _, active := dataset.activeCollectionByPDD[pdd.ID]; active {
|
||||
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "商品正在采集,请结束后刷新再操作"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stage.NextAction == "open_pdd" && (stage.Stage == ProcessStagePDDPending || stage.Stage == ProcessStagePDDCollectionFail ||
|
||||
(stage.Stage == ProcessStageManualAction && (disabled || preview.ReasonCode == "PDD_PRICE_MISSING"))) {
|
||||
return nil
|
||||
}
|
||||
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "处理阶段已变化,当前不能一键替换,请刷新列表"}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestQuickReplacementUsesCurrentStage(t *testing.T) {
|
||||
for _, name := range []string{"pending", "disabled", "price-missing", "ready", "parse-failed", "collecting", "purchase-pending", "purchase-running", "order-created", "order-unknown"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
allow := name == "pending" || name == "disabled" || name == "price-missing"
|
||||
switch name {
|
||||
case "pending", "disabled":
|
||||
db.Model(&f.pdd).Update("status", name)
|
||||
case "ready":
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
case "parse-failed":
|
||||
db.Model(&f.syb).Update("parse_status", "failed")
|
||||
case "collecting":
|
||||
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.CollectionTask{DeviceID: &f.device.ID, PDDProductID: &f.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusRunning, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "purchase-pending", "purchase-running", "order-created", "order-unknown":
|
||||
status := map[string]string{"purchase-pending": "pending", "purchase-running": "running", "order-created": "order_created", "order-unknown": "order_result_unknown"}[name]
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&models.PurchaseTask{ExecutionMode: "live", Quantity: 1, SYBProductID: &f.syb.ID, PDDProductID: f.pdd.ID, Status: status, CreateRequestID: name}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err := ValidateQuickReplacement(context.Background(), db, f.syb.ID)
|
||||
if (err == nil) != allow {
|
||||
t.Fatalf("allowed=%v: %v", allow, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
}
|
||||
|
||||
func (handler Handler) List(c *gin.Context) {
|
||||
page, err := queryInt(c.Query("page"), 1)
|
||||
@@ -106,6 +109,7 @@ func (handler Handler) LinkPDD(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service.ReplacementEligibility = handler.ReplacementEligibility
|
||||
response, err := service.LinkPDD(c.Request.Context(), id, request)
|
||||
respond(c, response, err)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,18 @@ const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
|
||||
const CodeLinkConflict = "PDD_LINK_CONFLICT"
|
||||
|
||||
type LatestCollectionRequest struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
Replacement *LatestCollectionReplacement `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
type LatestCollectionReplacement struct {
|
||||
Preview bool `json:"preview"`
|
||||
ExpectedPDDProductID uint64 `json:"expectedPddProductId"`
|
||||
TaskID uint64 `json:"taskId"`
|
||||
TargetPDDProductID uint64 `json:"targetPddProductId"`
|
||||
TargetVersion string `json:"targetVersion"`
|
||||
}
|
||||
|
||||
type LatestCollectionSource struct {
|
||||
@@ -23,6 +32,12 @@ type LatestCollectionSource struct {
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
GoodsID string `json:"goodsId,omitempty"`
|
||||
PreviousTitle string `json:"previousTitle,omitempty"`
|
||||
PreviousGoodsID string `json:"previousGoodsId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
}
|
||||
|
||||
func latestUnavailable(message string) error {
|
||||
@@ -56,10 +71,28 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
}
|
||||
return err
|
||||
}
|
||||
if shopee.PDDProductID != nil {
|
||||
replacement := r.Replacement
|
||||
var old models.PDDProduct
|
||||
if replacement != nil {
|
||||
if shopee.PDDProductID == nil || replacement.ExpectedPDDProductID == 0 || *shopee.PDDProductID != replacement.ExpectedPDDProductID {
|
||||
return contextVersionStale()
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&old, *shopee.PDDProductID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if service.ReplacementEligibility == nil {
|
||||
return invalidRequest("一键替换入口未配置")
|
||||
}
|
||||
if err := service.ReplacementEligibility(ctx, tx, syb.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !replacement.Preview && (replacement.TaskID == 0 || replacement.TargetPDDProductID == 0 || replacement.TargetVersion == "") {
|
||||
return invalidRequest("请先预览并确认替换商品")
|
||||
}
|
||||
} else if shopee.PDDProductID != nil {
|
||||
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
|
||||
}
|
||||
if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion {
|
||||
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, old.SpecsJSON) != r.SpecContextVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var device models.AgentDevice
|
||||
@@ -73,7 +106,11 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
return latestUnavailable("手机已停用,请重新选择")
|
||||
}
|
||||
var task models.CollectionTask
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("finished_at DESC, id DESC").First(&task).Error
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
|
||||
if replacement != nil && !replacement.Preview {
|
||||
query = query.Where("id = ?", replacement.TaskID)
|
||||
}
|
||||
err := query.Order("finished_at DESC, id DESC").First(&task).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
|
||||
}
|
||||
@@ -101,18 +138,40 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
if err != nil || len(color)+len(size) == 0 {
|
||||
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
|
||||
}
|
||||
result := tx.Model(&models.ShopeeProduct{}).Where("id = ? AND pdd_product_id IS NULL", id).Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON)
|
||||
if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) {
|
||||
return contextVersionStale()
|
||||
}
|
||||
// Preview never writes. Same-product continuation also preserves the
|
||||
// existing association and request marker; matching is a separate call.
|
||||
if replacement == nil || (!replacement.Preview && old.ID != pdd.ID) {
|
||||
query := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id)
|
||||
if replacement == nil {
|
||||
query = query.Where("pdd_product_id IS NULL")
|
||||
} else {
|
||||
query = query.Where("pdd_product_id = ?", old.ID)
|
||||
}
|
||||
result := query.Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return contextVersionStale()
|
||||
}
|
||||
}
|
||||
response, err = NewService(tx).Detail(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
|
||||
if replacement != nil {
|
||||
response.CollectionSource.TargetVersion = targetVersion
|
||||
response.CollectionSource.Title = pdd.Title
|
||||
response.CollectionSource.GoodsID = pdd.GoodsID
|
||||
response.CollectionSource.PreviousTitle = old.Title
|
||||
response.CollectionSource.PreviousGoodsID = old.GoodsID
|
||||
response.CollectionSource.DeviceName = device.Name
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func replacementFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask, models.PDDProduct) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
old := seedPDDProduct(t, s.DB, "pending")
|
||||
if err := s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", old.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := s.Detail(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
|
||||
req.LatestCollection.Replacement = &LatestCollectionReplacement{Preview: true, ExpectedPDDProductID: old.ID}
|
||||
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return nil }
|
||||
return s, id, req, task, old
|
||||
}
|
||||
|
||||
func previewReplacement(t *testing.T, s *Service, id uint64, req *LinkPDDRequest) SaveResponse {
|
||||
t.Helper()
|
||||
preview, err := s.LinkPDD(context.Background(), id, *req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := req.LatestCollection.Replacement
|
||||
r.Preview = false
|
||||
r.TaskID = preview.CollectionSource.TaskID
|
||||
r.TargetPDDProductID = preview.CollectionSource.PDDProductID
|
||||
r.TargetVersion = preview.CollectionSource.TargetVersion
|
||||
return preview
|
||||
}
|
||||
|
||||
func TestReplacementPreviewIsReadOnlyAndFreezesCandidate(t *testing.T) {
|
||||
s, id, req, task, old := replacementFixture(t)
|
||||
preview := previewReplacement(t, s, id, &req)
|
||||
if *preview.Product.PDDProductID != old.ID {
|
||||
t.Fatal("preview changed association")
|
||||
}
|
||||
var record models.ShopeeProduct
|
||||
s.DB.First(&record, id)
|
||||
if *record.PDDProductID != old.ID || record.LastUpdateRequestID != nil {
|
||||
t.Fatal("preview wrote")
|
||||
}
|
||||
newer := task
|
||||
newer.ID = 0
|
||||
newer.Status = models.TaskStatusCompletedPartial
|
||||
if err := s.DB.Create(&newer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil || *result.Product.PDDProductID != *task.PDDProductID || result.CollectionSource.TaskID != task.ID {
|
||||
t.Fatalf("frozen candidate: %+v %v", result, err)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
|
||||
t.Fatal("duplicate replaced stale association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementSameProductDoesNotRebind(t *testing.T) {
|
||||
s, id, req, task, _ := replacementFixture(t)
|
||||
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", *task.PDDProductID)
|
||||
detail, _ := s.Detail(context.Background(), id)
|
||||
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
|
||||
req.LatestCollection.Replacement.ExpectedPDDProductID = *task.PDDProductID
|
||||
previewReplacement(t, s, id, &req)
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record models.ShopeeProduct
|
||||
s.DB.First(&record, id)
|
||||
if record.LastUpdateRequestID != nil {
|
||||
t.Fatal("same product wrote association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementRejectsChangesAfterPreview(t *testing.T) {
|
||||
for _, change := range []string{"association", "specs", "target-specs", "task-status", "device", "stage", "missing-validator", "target-id", "task-id"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
s, id, req, task, old := replacementFixture(t)
|
||||
previewReplacement(t, s, id, &req)
|
||||
expected := old.ID
|
||||
switch change {
|
||||
case "association":
|
||||
other := seedPDDProduct(t, s.DB, "active")
|
||||
expected = other.ID
|
||||
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", expected)
|
||||
case "specs":
|
||||
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("specs_json", "[]")
|
||||
case "target-specs":
|
||||
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"白色","selectable":true}]}]`)
|
||||
case "task-status":
|
||||
s.DB.Model(&task).Update("status", "completed_partial")
|
||||
case "device":
|
||||
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("status", "disabled")
|
||||
case "stage":
|
||||
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return &ServiceError{Code: CodeLinkConflict} }
|
||||
case "missing-validator":
|
||||
s.ReplacementEligibility = nil
|
||||
case "target-id":
|
||||
req.LatestCollection.Replacement.TargetPDDProductID++
|
||||
case "task-id":
|
||||
req.LatestCollection.Replacement.TaskID++
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
|
||||
t.Fatal("accepted changed context")
|
||||
}
|
||||
var record models.ShopeeProduct
|
||||
s.DB.First(&record, id)
|
||||
if *record.PDDProductID != expected {
|
||||
t.Fatal("overwrote rejected association")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementConcurrentConfirmationsOnlyOneWrites(t *testing.T) {
|
||||
s, id, req, _, _ := replacementFixture(t)
|
||||
previewReplacement(t, s, id, &req)
|
||||
connection, err := s.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection.SetMaxOpenConns(1) // SQLite serialization, not a MySQL lock claim.
|
||||
results := make(chan error, 2)
|
||||
for range 2 {
|
||||
go func() { _, err := s.LinkPDD(context.Background(), id, req); results <- err }()
|
||||
}
|
||||
wins := 0
|
||||
for range 2 {
|
||||
if <-results == nil {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("writes=%d", wins)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware, eligibility ReplacementEligibility) {
|
||||
handler := Handler{ReplacementEligibility: eligibility}
|
||||
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
|
||||
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
|
||||
|
||||
@@ -57,7 +57,12 @@ func (err *ServiceError) Error() string {
|
||||
}
|
||||
func (err *ServiceError) Unwrap() error { return err.Cause }
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
<div class="quick-link-actions">
|
||||
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
|
||||
<el-button-group v-if="quickCanLink(row)">
|
||||
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">一键关联</el-button>
|
||||
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">{{ quickActionLabel(row) }}</el-button>
|
||||
<el-dropdown trigger="click" @command="quickOpenPicker()">
|
||||
<el-button size="small" type="primary" plain aria-label="一键关联使用的手机" :icon="ArrowDown" />
|
||||
<el-button size="small" type="primary" plain :aria-label="`${quickActionLabel(row)}使用的手机`" :icon="ArrowDown" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
|
||||
@@ -57,7 +57,7 @@
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||
<p>{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}</p>
|
||||
<p>{{ quickPicker.row ? '选择后继续处理刚才的商品;更换为其他商品前会再次确认。' : '只修改各行共用的手机,不启动关联或替换,也不影响正在进行的操作。' }}</p>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="采集手机">
|
||||
<el-select v-model="quickPicker.deviceId" :loading="quickDeviceLoading" placeholder="请选择手机" style="width:100%">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { getShopeeProduct, linkShopeeProductPdd, autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products'
|
||||
import { previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
deactivated() { this.quickInvalidate() },
|
||||
methods: {
|
||||
quickInvalidate() {
|
||||
if (this.quickPhase === '等待替换确认') ElMessageBox.close()
|
||||
this.quickGeneration++
|
||||
this.quickPickerGeneration++
|
||||
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
|
||||
@@ -29,7 +30,16 @@ export default {
|
||||
},
|
||||
quickCanLink(row) {
|
||||
const ready = this.purchaseReady(row)
|
||||
return this.canPurchase && !!row.shopeeProductId && ready.processNextAction === 'open_pdd_link' && !ready.pddProductId
|
||||
return this.canPurchase && !!row.shopeeProductId && ((ready.processNextAction === 'open_pdd_link' && !ready.pddProductId) || this.quickCanReplace(row))
|
||||
},
|
||||
quickCanReplace(row) {
|
||||
const ready = this.purchaseReady(row)
|
||||
return !!ready.pddProductId && ready.processNextAction === 'open_pdd' && (
|
||||
['pdd_pending', 'pdd_collection_failed'].includes(ready.processStage) ||
|
||||
(ready.processStage === 'manual_action' && ['PDD_NOT_READY', 'PDD_PRICE_MISSING'].includes(ready.reasonCode)))
|
||||
},
|
||||
quickActionLabel(row) {
|
||||
return this.quickCanReplace(row) ? '一键替换' : '一键关联'
|
||||
},
|
||||
quickDeviceLabel() {
|
||||
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
|
||||
@@ -61,34 +71,60 @@ export default {
|
||||
async quickStart(row) {
|
||||
if (!this.quickCanLink(row) || this.quickBusyId) return
|
||||
if (!this.quickDeviceId) return this.quickOpenPicker(row)
|
||||
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId }
|
||||
const replacing = this.quickCanReplace(row)
|
||||
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId, oldPddId: this.purchaseReady(row).pddProductId }
|
||||
const generation = ++this.quickGeneration
|
||||
const current = () => generation === this.quickGeneration
|
||||
this.quickBusyId = row.id
|
||||
this.quickMessages[row.id] = ''
|
||||
this.quickPhase = '正在关联'
|
||||
let wrote = false
|
||||
let targetId = null
|
||||
try {
|
||||
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
|
||||
if (!current()) return
|
||||
if (detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
|
||||
if (!replacing && detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
|
||||
if (replacing && detail.product.pddProductId !== frozen.oldPddId) throw new Error('关联已变化,请刷新列表')
|
||||
const latestCollection = { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
|
||||
if (replacing) {
|
||||
this.quickPhase = '正在读取最近采集'
|
||||
const preview = (await linkShopeeProductPdd(frozen.productId, {
|
||||
requestId: createRequestId(), latestCollection: { ...latestCollection, replacement: { preview: true, expectedPddProductId: frozen.oldPddId }}
|
||||
}, readOptions)).data
|
||||
if (!current()) return
|
||||
const source = preview.collectionSource
|
||||
targetId = source.pddProductId
|
||||
latestCollection.replacement = { preview: false, expectedPddProductId: frozen.oldPddId, taskId: source.taskId, targetPddProductId: targetId, targetVersion: source.targetVersion }
|
||||
if (targetId !== frozen.oldPddId) {
|
||||
this.quickPhase = '等待替换确认'
|
||||
try {
|
||||
await ElMessageBox.confirm(`将「${source.previousTitle || '原商品'}(${source.previousGoodsId || frozen.oldPddId})」替换为「${source.title || '最近采集商品'}(${source.goodsId || targetId})」。来源:${source.deviceName || '手机'} #${frozen.deviceId}。共用此蝦皮商品的 SYB 明细也会使用新关联;已有采购任务不变。`, '确认替换 PDD 商品', { confirmButtonText: '确认替换并匹配', cancelButtonText: '取消', closeOnClickModal: false })
|
||||
} catch {
|
||||
if (current()) this.quickMessages[row.id] = '已取消,未修改关联'
|
||||
return
|
||||
}
|
||||
if (!current()) return
|
||||
}
|
||||
this.quickPhase = targetId === frozen.oldPddId ? '正在核对现有关联' : '正在替换'
|
||||
}
|
||||
const linked = (await linkShopeeProductPdd(frozen.productId, {
|
||||
requestId: createRequestId(),
|
||||
latestCollection: { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
|
||||
latestCollection
|
||||
}, readOptions)).data
|
||||
wrote = true
|
||||
if (!current()) return
|
||||
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
|
||||
if (!current()) return
|
||||
const outcome = replacing && targetId !== frozen.oldPddId ? '已替换' : '已关联'
|
||||
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
|
||||
this.quickMessages[row.id] = ready?.eligible ? '已关联,当前明细可创建采购' : '已关联;请按处理阶段提示继续人工处理'
|
||||
this.quickMessages[row.id] = ready?.eligible ? `${outcome},当前明细可创建采购` : `${outcome};请按处理阶段提示继续人工处理`
|
||||
} else {
|
||||
this.quickPhase = '正在匹配并保存'
|
||||
const result = (await autoMatchShopeeSpecMappings(frozen.productId, {
|
||||
requestId: createRequestId(), specContextVersion: linked.product.specContextVersion
|
||||
}, detail.autoMatchTimeoutSeconds)).data
|
||||
if (!current()) return
|
||||
this.quickMessages[row.id] = result.unmatchedCount ? '已关联并保存有效匹配;剩余规格请去匹配处理' : '已关联并保存匹配;以最新处理阶段为准'
|
||||
this.quickMessages[row.id] = result.unmatchedCount ? `${outcome}并保存有效匹配;剩余规格请去匹配处理` : `${outcome}并保存匹配;以最新处理阶段为准`
|
||||
}
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
@@ -102,9 +138,13 @@ export default {
|
||||
try {
|
||||
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
|
||||
if (!current()) return
|
||||
this.quickMessages[row.id] = saved.pddProductId
|
||||
? `当前已有关联,匹配结果请去匹配查看。${reason}`
|
||||
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
|
||||
this.quickMessages[row.id] = replacing
|
||||
? (targetId && saved.pddProductId === targetId
|
||||
? `当前关联为本次目标商品,匹配结果请去匹配查看。${reason}`
|
||||
: `当前关联${saved.pddProductId === frozen.oldPddId ? '仍为原商品' : '已变化'};本次结果未确认,请刷新核对,不自动重试。${reason}`)
|
||||
: saved.pddProductId
|
||||
? `当前已有关联,匹配结果请去匹配查看。${reason}`
|
||||
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
|
||||
} catch {
|
||||
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
async function setup(page: any) {
|
||||
async function setup(page: any, replacing = false) {
|
||||
const state = { userId: 1, role: 'purchaser', links: [] as any[], matches: [] as any[], linked: new Set<number>(), matched: new Set<number>(), aiFailure: false }
|
||||
const replaced = new Set<number>()
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async (route: any) => {
|
||||
const url = new URL(route.request().url()), path = url.pathname
|
||||
@@ -11,13 +12,18 @@ async function setup(page: any) {
|
||||
if (path.endsWith('/menurole')) return ok([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }])
|
||||
if (path.endsWith('/syb-products')) return ok({ items: [1, 2].map(id => ({ id, orderCode: `TEST-${id}`, shopeeItemId: `100${id}`, shopeeProductId: id + 10, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' })), total: 2 })
|
||||
if (path.endsWith('/devices')) return ok({ items: [{ id: 10, name: '手机A', status: 'offline' }, { id: 20, name: '手机B', status: 'online' }], total: 2 })
|
||||
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => ({ sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' })) })
|
||||
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => replacing && !replaced.has(id)
|
||||
? { sybProductId: id, pddProductId: 44, processStage: 'pdd_collection_failed', processNextAction: 'open_pdd' }
|
||||
: { sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' }) })
|
||||
const match = path.match(/shopee-products\/(\d+)(.*)/)
|
||||
if (match) {
|
||||
const productId = Number(match[1]), id = productId - 10
|
||||
const product = { id: productId, pddProductId: state.linked.has(id) ? 55 : null, specContextVersion: state.linked.has(id) ? 'after' : 'before', specs: [] }
|
||||
if (replacing && !replaced.has(id)) product.pddProductId = 44
|
||||
if (match[2] === '/link-pdd') {
|
||||
const body = route.request().postDataJSON(); state.links.push(body); state.linked.add(id)
|
||||
const body = route.request().postDataJSON()
|
||||
if (body.latestCollection.replacement?.preview) return ok({ product, collectionSource: { taskId: 7, deviceId: 10, pddProductId: 55, targetVersion: 'frozen', title: '新测试商品', previousTitle: '原测试商品', deviceName: '手机A' } })
|
||||
state.links.push(body); state.linked.add(id); replaced.add(id)
|
||||
return ok({ product: { ...product, pddProductId: 55, specContextVersion: 'after' }, collectionSource: { taskId: 7, deviceId: body.latestCollection.deviceId, pddProductId: 55 } })
|
||||
}
|
||||
if (match[2] === '/specs/mapping/auto-match') {
|
||||
@@ -30,12 +36,32 @@ async function setup(page: any) {
|
||||
return ok([])
|
||||
})
|
||||
await page.goto('/#/syb-products')
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(2)
|
||||
await expect(page.getByRole('button', { name: replacing ? '一键替换' : '一键关联', exact: true })).toHaveCount(2)
|
||||
return state
|
||||
}
|
||||
|
||||
test('replacement reuses row position; cancel writes nothing; confirm freezes target and matches', async ({ page }, testInfo) => {
|
||||
const state = await setup(page, true)
|
||||
await expect(page.getByRole('button', { name: '查看 PDD 商品', exact: true })).toHaveCount(2)
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '一键替换', exact: true }).first().click()
|
||||
await choose(page, '手机A', '选择并继续')
|
||||
const confirm = page.getByRole('dialog', { name: '确认替换 PDD 商品' })
|
||||
await expect(confirm).toContainText('共用此蝦皮商品')
|
||||
await page.screenshot({ path: testInfo.outputPath('replacement-confirm.png'), animations: 'disabled' })
|
||||
await confirm.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(state.links).toHaveLength(0); expect(state.matches).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '一键替换', exact: true }).first().click()
|
||||
await confirm.getByRole('button', { name: '确认替换并匹配', exact: true }).click()
|
||||
await expect.poll(() => state.matches.length).toBe(1)
|
||||
expect(state.links).toHaveLength(1)
|
||||
expect(state.links[0].latestCollection.replacement).toMatchObject({ expectedPddProductId: 44, taskId: 7, targetPddProductId: 55, targetVersion: 'frozen' })
|
||||
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
|
||||
await expect(confirm).toBeHidden()
|
||||
})
|
||||
|
||||
async function choose(page: any, name: string, button: string) {
|
||||
const dialog = page.getByRole('dialog')
|
||||
const dialog = page.getByRole('dialog', { name: /^(选择|切换)采集手机$/ })
|
||||
await dialog.locator('.el-select').click()
|
||||
await page.getByRole('option', { name: new RegExp(name) }).click()
|
||||
await dialog.getByRole('button', { name: button, exact: true }).click()
|
||||
|
||||
@@ -10,6 +10,7 @@ function instance(overrides = {}) {
|
||||
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
|
||||
const dependencies = {
|
||||
ElMessage: { warning() {} },
|
||||
ElMessageBox: { confirm: async () => {}, close() {} },
|
||||
listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }),
|
||||
getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } },
|
||||
linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } },
|
||||
@@ -87,3 +88,48 @@ test('preference isolates user and API environment, handles storage failure', ()
|
||||
process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null)
|
||||
storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false)
|
||||
})
|
||||
|
||||
function replacementInstance(overrides = {}) {
|
||||
const writes = [], previews = [], confirms = []
|
||||
const context = instance({
|
||||
getShopeeProduct: async () => ({ data: { product: { pddProductId: 44, specContextVersion: 'old' }, autoMatchTimeoutSeconds: 130 } }),
|
||||
ElMessageBox: { confirm: async (...args) => { confirms.push(args) }, close() {} },
|
||||
linkShopeeProductPdd: async (id, body) => {
|
||||
const r = body.latestCollection.replacement
|
||||
;(r.preview ? previews : writes).push(body)
|
||||
return { data: { product: { specContextVersion: 'new', pddProductId: r.preview ? 44 : 55 }, collectionSource: { taskId: 9, pddProductId: 55, targetVersion: 'target', title: '新商品', previousTitle: '原商品', deviceName: 'A' } } }
|
||||
}, ...overrides
|
||||
})
|
||||
context.vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: 'pdd_collection_failed' })
|
||||
context.vm.quickDeviceId = 10
|
||||
return { ...context, writes, previews, confirms }
|
||||
}
|
||||
test('replacement confirms once and submits the frozen source', async () => {
|
||||
const { vm, writes, previews, confirms, calls } = replacementInstance()
|
||||
await vm.quickStart(row)
|
||||
assert.equal(previews.length, 1); assert.equal(confirms.length, 1); assert.equal(writes.length, 1)
|
||||
assert.deepEqual(writes[0].latestCollection.replacement, { preview: false, expectedPddProductId: 44, taskId: 9, targetPddProductId: 55, targetVersion: 'target' })
|
||||
assert.equal(calls.match.length, 1)
|
||||
})
|
||||
test('cancel replacement never writes or matches', async () => {
|
||||
const { vm, writes, calls } = replacementInstance({ ElMessageBox: { confirm: async () => { throw 'cancel' } } })
|
||||
await vm.quickStart(row)
|
||||
assert.equal(writes.length, 0); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /已取消/)
|
||||
})
|
||||
test('same product continues matching without replacement confirmation', async () => {
|
||||
const { vm, confirms, calls } = replacementInstance({ linkShopeeProductPdd: async () => ({ data: { product: { specContextVersion: 'old' }, collectionSource: { taskId: 9, pddProductId: 44, targetVersion: 'same' } } }) })
|
||||
await vm.quickStart(row)
|
||||
assert.equal(confirms.length, 0); assert.equal(calls.match.length, 1)
|
||||
})
|
||||
test('failed replacement readback does not call old association a success', async () => {
|
||||
const { vm, calls } = replacementInstance({ linkShopeeProductPdd: async () => { throw new Error('timeout') } })
|
||||
await vm.quickStart(row)
|
||||
assert.match(vm.quickMessages[1], /仍为原商品/); assert.equal(calls.match.length, 0)
|
||||
})
|
||||
test('replacement is not shown for collecting, successful, unknown or other manual stages', () => {
|
||||
const { vm } = replacementInstance()
|
||||
for (const stage of ['pdd_collecting', 'purchase_succeeded', 'order_review', 'task_created', 'color_mapping', 'manual_action']) {
|
||||
vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: stage })
|
||||
assert.equal(vm.quickCanLink(row), false, stage)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user