feat(#80): add SYB product process stages
This commit is contained in:
@@ -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: 45bbde203b18cee7031aed8129f72a6e8f883033
|
||||
synchronized_at: 2026-08-24T09:26:57Z
|
||||
wiki_revision: 73e38d6204acee7b6ea2c2eb9e9c918e807dbf3a
|
||||
synchronized_at: 2026-08-25T01:39:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -49,6 +49,16 @@ synchronized_at: 2026-08-24T09:26:57Z
|
||||
- Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。
|
||||
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
|
||||
|
||||
|
||||
### SYB 商品处理阶段
|
||||
|
||||
- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、颜色待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 颜色待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
## SYB 店铺过滤
|
||||
|
||||
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
|
||||
|
||||
@@ -53,6 +53,10 @@ type BatchPreviewItem struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
NextAction string `json:"nextAction,omitempty"`
|
||||
ActiveTaskID *uint64 `json:"activeTaskId,omitempty"`
|
||||
ProcessStage string `json:"processStage,omitempty"`
|
||||
ProcessStageLabel string `json:"processStageLabel,omitempty"`
|
||||
ProcessStageReason string `json:"processStageReason,omitempty"`
|
||||
ProcessNextAction string `json:"processNextAction,omitempty"`
|
||||
}
|
||||
|
||||
type BatchPreviewResponse struct {
|
||||
@@ -89,6 +93,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
eligible := 0
|
||||
for _, id := range ids {
|
||||
item := s.previewFromDataset(ctx, id, dataset, false)
|
||||
item.applyProcessStage(processStageFromDataset(id, dataset, item))
|
||||
if item.Eligible && deviceErr != nil {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr)
|
||||
@@ -102,21 +107,23 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
}
|
||||
|
||||
type batchPreviewDataset struct {
|
||||
sybByID map[uint64]models.SYBProduct
|
||||
shopeeByID map[uint64]models.ShopeeProduct
|
||||
pddByID map[uint64]models.PDDProduct
|
||||
latestTaskBySYB map[uint64]models.PurchaseTask
|
||||
sybByID map[uint64]models.SYBProduct
|
||||
shopeeByID map[uint64]models.ShopeeProduct
|
||||
pddByID map[uint64]models.PDDProduct
|
||||
latestTaskBySYB map[uint64]models.PurchaseTask
|
||||
latestCollectionByPDD map[uint64]models.CollectionTask
|
||||
}
|
||||
|
||||
// loadBatchPreviewDataset keeps the read-only preview bounded to four bulk
|
||||
// queries regardless of the number of rows on the current page. Creation still
|
||||
// revalidates each item against current data before persisting a task.
|
||||
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
||||
// Pending PDD products add one collection-task query; active products retain
|
||||
// the original four-query fast path. Creation still revalidates each item.
|
||||
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
|
||||
dataset := batchPreviewDataset{
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
}
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||
@@ -149,6 +156,23 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
for _, pdd := range pddProducts {
|
||||
dataset.pddByID[pdd.ID] = pdd
|
||||
}
|
||||
pendingPDDIDs := make([]uint64, 0)
|
||||
for _, pdd := range pddProducts {
|
||||
if pdd.Status == "pending" {
|
||||
pendingPDDIDs = append(pendingPDDIDs, pdd.ID)
|
||||
}
|
||||
}
|
||||
if len(pendingPDDIDs) > 0 {
|
||||
var collectionTasks []models.CollectionTask
|
||||
if err := s.DB.WithContext(ctx).
|
||||
Where("pdd_product_id IN ? AND id = (SELECT MAX(ct2.id) FROM collection_task ct2 WHERE ct2.pdd_product_id = collection_task.pdd_product_id AND ct2.deleted_at IS NULL)", pendingPDDIDs).
|
||||
Find(&collectionTasks).Error; err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
for _, task := range collectionTasks {
|
||||
dataset.latestCollectionByPDD[task.PDDProductID] = task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var tasks []models.PurchaseTask
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ProcessStageManualAction = "manual_action"
|
||||
ProcessStagePDDUnlinked = "pdd_unlinked"
|
||||
ProcessStagePDDPending = "pdd_pending"
|
||||
ProcessStagePDDCollecting = "pdd_collecting"
|
||||
ProcessStagePDDCollectionFail = "pdd_collection_failed"
|
||||
ProcessStageColorMapping = "color_mapping"
|
||||
ProcessStagePurchaseReady = "purchase_ready"
|
||||
ProcessStageTaskCreated = "task_created"
|
||||
ProcessStagePurchaseSucceeded = "purchase_succeeded"
|
||||
ProcessStageOrderReview = "order_review"
|
||||
)
|
||||
|
||||
var processStageLabels = map[string]string{
|
||||
ProcessStageManualAction: "待人工处理",
|
||||
ProcessStagePDDUnlinked: "未关联 PDD",
|
||||
ProcessStagePDDPending: "PDD 待采集",
|
||||
ProcessStagePDDCollecting: "PDD 采集中",
|
||||
ProcessStagePDDCollectionFail: "PDD 采集失败",
|
||||
ProcessStageColorMapping: "颜色待匹配",
|
||||
ProcessStagePurchaseReady: "可创建采购",
|
||||
ProcessStageTaskCreated: "已创建任务",
|
||||
ProcessStagePurchaseSucceeded: "采购成功",
|
||||
ProcessStageOrderReview: "待人工核对",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
Stage string
|
||||
Label string
|
||||
Reason string
|
||||
NextAction string
|
||||
ActiveTaskID *uint64
|
||||
}
|
||||
|
||||
func ValidProcessStage(stage string) bool {
|
||||
if strings.TrimSpace(stage) == "" {
|
||||
return true
|
||||
}
|
||||
_, ok := processStageLabels[stage]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ProcessStages derives one user-facing workflow stage per SYB product. It is
|
||||
// deliberately read-only and never calls the AI matcher.
|
||||
func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]ProcessStageResult, error) {
|
||||
result := make(map[uint64]ProcessStageResult, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
preview := s.previewFromDataset(ctx, id, dataset, false)
|
||||
result[id] = processStageFromDataset(id, dataset, preview)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (item *BatchPreviewItem) applyProcessStage(stage ProcessStageResult) {
|
||||
item.ProcessStage = stage.Stage
|
||||
item.ProcessStageLabel = stage.Label
|
||||
item.ProcessStageReason = stage.Reason
|
||||
item.ProcessNextAction = stage.NextAction
|
||||
if stage.ActiveTaskID != nil {
|
||||
item.ActiveTaskID = stage.ActiveTaskID
|
||||
}
|
||||
}
|
||||
|
||||
func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview BatchPreviewItem) ProcessStageResult {
|
||||
stage := func(value, reason, next string) ProcessStageResult {
|
||||
return ProcessStageResult{Stage: value, Label: processStageLabels[value], Reason: reason, NextAction: next}
|
||||
}
|
||||
if task, ok := dataset.latestTaskBySYB[id]; ok {
|
||||
taskID := task.ID
|
||||
switch task.Status {
|
||||
case models.PurchaseTaskStatusOrderResultUnknown:
|
||||
out := stage(ProcessStageOrderReview, "订单结果不明确,请先人工核对,禁止重试", "open_task")
|
||||
out.ActiveTaskID = &taskID
|
||||
return out
|
||||
case models.PurchaseTaskStatusOrderCreated:
|
||||
out := stage(ProcessStagePurchaseSucceeded, "已取得订单号和下单时间,不代表已支付", "open_task")
|
||||
out.ActiveTaskID = &taskID
|
||||
return out
|
||||
case models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending,
|
||||
models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted:
|
||||
out := stage(ProcessStageTaskCreated, "已有未结束的采购任务", "open_task")
|
||||
out.ActiveTaskID = &taskID
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
syb, ok := dataset.sybByID[id]
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||
}
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return stage(ProcessStageManualAction, "尚未关联蝦皮商品", "refresh")
|
||||
}
|
||||
shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "关联的蝦皮商品不存在", "open_shopee")
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return stage(ProcessStagePDDUnlinked, "请先关联拼多多商品", "open_mapping")
|
||||
}
|
||||
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !ok {
|
||||
return stage(ProcessStagePDDUnlinked, "关联的拼多多商品不存在", "open_mapping")
|
||||
}
|
||||
if pdd.Status == "disabled" {
|
||||
return stage(ProcessStageManualAction, "拼多多商品已停用", "open_pdd")
|
||||
}
|
||||
if pdd.Status == "pending" {
|
||||
if task, found := dataset.latestCollectionByPDD[pdd.ID]; found {
|
||||
switch task.Status {
|
||||
case models.TaskStatusPending, models.TaskStatusRunning:
|
||||
return stage(ProcessStagePDDCollecting, "商品采集任务正在执行", "open_pdd")
|
||||
case models.TaskStatusFailed:
|
||||
return stage(ProcessStagePDDCollectionFail, "最近一次商品采集失败", "open_pdd")
|
||||
}
|
||||
}
|
||||
return stage(ProcessStagePDDPending, "拼多多商品尚未采集完成", "open_pdd")
|
||||
}
|
||||
if preview.ReasonCode == CodeMappingRequired {
|
||||
return stage(ProcessStageColorMapping, "颜色匹配无效,请重新选择", "open_mapping")
|
||||
}
|
||||
if preview.Eligible {
|
||||
return stage(ProcessStagePurchaseReady, "当前条件可以创建采购任务", "")
|
||||
}
|
||||
reason := strings.TrimSpace(preview.Reason)
|
||||
if reason == "" {
|
||||
reason = "请检查商品和规格数据"
|
||||
}
|
||||
return stage(ProcessStageManualAction, reason, preview.NextAction)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
|
||||
base := func() batchPreviewDataset {
|
||||
pddID := uint64(3)
|
||||
shopeeID := uint64(2)
|
||||
return batchPreviewDataset{
|
||||
sybByID: map[uint64]models.SYBProduct{1: {ID: 1, ParseStatus: models.SYBParseStatusSuccess, ShopeeProductID: &shopeeID}},
|
||||
shopeeByID: map[uint64]models.ShopeeProduct{2: {ID: 2, PDDProductID: &pddID}},
|
||||
pddByID: map[uint64]models.PDDProduct{3: {ID: 3, Status: "active"}},
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
set func(*batchPreviewDataset, *BatchPreviewItem)
|
||||
}{
|
||||
{"待人工处理", ProcessStageManualAction, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.sybByID[1]
|
||||
row.ParseStatus = models.SYBParseStatusFailed
|
||||
d.sybByID[1] = row
|
||||
}},
|
||||
{"未关联 PDD", ProcessStagePDDUnlinked, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.shopeeByID[2]
|
||||
row.PDDProductID = nil
|
||||
d.shopeeByID[2] = row
|
||||
}},
|
||||
{"PDD 待采集", ProcessStagePDDPending, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
}},
|
||||
{"PDD 采集中", ProcessStagePDDCollecting, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusRunning}
|
||||
}},
|
||||
{"PDD 采集失败", ProcessStagePDDCollectionFail, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusFailed}
|
||||
}},
|
||||
{"颜色待匹配", ProcessStageColorMapping, func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }},
|
||||
{"可创建采购", ProcessStagePurchaseReady, func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }},
|
||||
{"已创建任务", ProcessStageTaskCreated, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
d.latestTaskBySYB[1] = models.PurchaseTask{ID: 11, SYBProductID: &sybID, Status: models.PurchaseTaskStatusRunning}
|
||||
}},
|
||||
{"采购成功", ProcessStagePurchaseSucceeded, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
d.latestTaskBySYB[1] = models.PurchaseTask{ID: 12, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderCreated}
|
||||
}},
|
||||
{"待人工核对", ProcessStageOrderReview, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
d.latestTaskBySYB[1] = models.PurchaseTask{ID: 13, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderResultUnknown}
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dataset := base()
|
||||
preview := BatchPreviewItem{SYBProductID: 1}
|
||||
tt.set(&dataset, &preview)
|
||||
got := processStageFromDataset(1, dataset, preview)
|
||||
if got.Stage != tt.want || got.Label != tt.name {
|
||||
t.Fatalf("got stage=%q label=%q, want %q/%q", got.Stage, got.Label, tt.want, tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidProcessStageRejectsInternalOrUnknownValues(t *testing.T) {
|
||||
if !ValidProcessStage("") || !ValidProcessStage(ProcessStagePurchaseReady) || ValidProcessStage("running") || ValidProcessStage("unknown") {
|
||||
t.Fatal("process stage validation accepted an internal or unknown value")
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")),
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchase"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -42,6 +43,7 @@ type ListRequest struct {
|
||||
ShopName string
|
||||
OrderCodes []string
|
||||
ParseStatus string
|
||||
ProcessStage string
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
@@ -51,10 +53,11 @@ type ListResponse struct {
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// List does not accept a "linked to purchase task" filter and never will:
|
||||
// this table has no purchase field to filter on (#41: 不做什么). The
|
||||
// "采购状态" column the prototype reserves for #35/#44 is injected by that
|
||||
// unit's own query, not by this endpoint.
|
||||
const processStageBatchSize = 500
|
||||
|
||||
// List keeps workflow stages derived from the current related facts instead of
|
||||
// persisting them on syb_product. With a processStage filter, derivation must
|
||||
// happen before pagination so totals and later pages stay accurate.
|
||||
func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) {
|
||||
if request.Page < 1 {
|
||||
request.Page = 1
|
||||
@@ -85,6 +88,49 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
}
|
||||
query = query.Where("parse_status = ?", request.ParseStatus)
|
||||
}
|
||||
request.ProcessStage = strings.TrimSpace(request.ProcessStage)
|
||||
if !purchase.ValidProcessStage(request.ProcessStage) {
|
||||
return ListResponse{}, invalidRequest("processStage 无效")
|
||||
}
|
||||
if request.ProcessStage != "" {
|
||||
var candidates []models.SYBProduct
|
||||
if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
}
|
||||
matched := make([]models.SYBProduct, 0)
|
||||
stageService := purchase.NewService(service.DB)
|
||||
for start := 0; start < len(candidates); start += processStageBatchSize {
|
||||
end := start + processStageBatchSize
|
||||
if end > len(candidates) {
|
||||
end = len(candidates)
|
||||
}
|
||||
ids := make([]uint64, 0, end-start)
|
||||
for _, item := range candidates[start:end] {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
stages, err := stageService.ProcessStages(ctx, ids)
|
||||
if err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
}
|
||||
for _, item := range candidates[start:end] {
|
||||
if stages[item.ID].Stage == request.ProcessStage {
|
||||
matched = append(matched, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
total := int64(len(matched))
|
||||
start := (request.Page - 1) * request.PageSize
|
||||
if start >= len(matched) {
|
||||
matched = nil
|
||||
} else {
|
||||
end := start + request.PageSize
|
||||
if end > len(matched) {
|
||||
end = len(matched)
|
||||
}
|
||||
matched = matched[start:end]
|
||||
}
|
||||
return ListResponse{Items: matched, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
|
||||
@@ -102,6 +102,43 @@ func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListFiltersProcessStageBeforePagination(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i := 0; i < 3; i++ {
|
||||
rowOrder := order
|
||||
rowOrder.Code = fmt.Sprintf("STAGE-%d", i)
|
||||
rowOrder.StockID += uint64(i)
|
||||
detail := realDetailA()
|
||||
detail.ID += uint64(i)
|
||||
if _, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var failed models.SYBProduct
|
||||
if err := db.Where("order_code = ?", "STAGE-2").First(&failed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&failed).Updates(map[string]any{"parse_status": models.SYBParseStatusFailed, "target_color": "", "target_size": ""}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
page, err := service.List(context.Background(), sybimport.ListRequest{Page: 2, PageSize: 1, ProcessStage: "pdd_unlinked"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 2 || len(page.Items) != 1 {
|
||||
t.Fatalf("stage filtering must happen before pagination: %+v", page)
|
||||
}
|
||||
combined, err := service.List(context.Background(), sybimport.ListRequest{ParseStatus: models.SYBParseStatusFailed, ProcessStage: "manual_action"})
|
||||
if err != nil || combined.Total != 1 || combined.Items[0].OrderCode != "STAGE-2" {
|
||||
t.Fatalf("parse and process filters did not combine: response=%+v err=%v", combined, err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), sybimport.ListRequest{ProcessStage: "running"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
|
||||
t.Fatalf("invalid process stage was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDetailNotFound(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="canPurchase" type="primary" :disabled="selectedProducts.length === 0" @click="openPurchaseBatch">创建采购</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="表头全选只选择当前页中可以创建采购任务的商品;不可创建的原因显示在“采购准备”列。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-alert v-if="canPurchase" title="表头全选只选择当前页中可以创建采购任务的商品;当前进度和下一步显示在“处理阶段”列。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
@@ -24,7 +25,7 @@
|
||||
<el-table-column label="数量" prop="quantity" width="56" />
|
||||
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
|
||||
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="canPurchase" label="采购准备" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else-if="purchaseReady(row).eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div></template><template v-else><el-tag type="warning">暂不可创建</el-tag><div class="purchase-reason">{{ purchaseReady(row).reason || '请刷新后重试' }}</div><el-button v-if="purchaseActionLabel(purchaseReady(row))" type="primary" link @click="runPurchaseNextAction(row, purchaseReady(row))">{{ purchaseActionLabel(purchaseReady(row)) }}</el-button></template></template></el-table-column>
|
||||
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else><el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag><div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div><div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div><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></template></template></el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && purchaseReady(row).eligible" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
@@ -134,13 +135,14 @@ export default {
|
||||
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
|
||||
purchaseDialog: { open: false, loading: false, saving: false, ids: [], deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
|
||||
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
detail: { open: false, loading: false, item: null },
|
||||
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '颜色待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
|
||||
firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null },
|
||||
formattedRaw() {
|
||||
if (!this.detail.item) return ''
|
||||
@@ -158,7 +160,7 @@ export default {
|
||||
this.$refs.productTable?.clearSelection()
|
||||
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
||||
try {
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus }, requestOptions)
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions)
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.products = r.data.items
|
||||
this.total = r.data.total
|
||||
@@ -177,7 +179,7 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
|
||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||
async ensureShopOptions() {
|
||||
if (this.shopOptionsLoaded) return
|
||||
@@ -204,6 +206,7 @@ export default {
|
||||
rowSelectable(row) { return this.canPurchase && this.purchaseReady(row).eligible },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '颜色待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
|
||||
@@ -10,7 +10,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-shops')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 1, displayName: '大碼臻選', enabled: true }, { id: 2, displayName: '歷史店鋪', enabled: false }], total: 2, enabledCount: 1, page: 1, pageSize: 200 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products')) { listURLs.push(url); return route.fulfill({ json: { code: 200, data: { items: [{ id: 1, orderCode: 'SYB001', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '用于验证加宽后的虾皮商品标题区域', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 2, unitPriceCent: 1000, parseStatus: 'success', manuallyConfirmed: false }, { id: 2, orderCode: 'SYB002', shopeeItemId: '26154802795', productTitle: '尚未关联的商品', shopName: '大碼臻選', targetColor: '白色', targetSize: 'L', quantity: 1, unitPriceCent: 900, parseStatus: 'success', manuallyConfirmed: false }], total: 2, page: 1, pageSize: 20 }}}) }
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 1, eligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500 }, { sybProductId: 2, eligible: false, reason: '尚未关联蝦皮商品' }], eligibleCount: 1, skippedCount: 1 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 1, eligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, { sybProductId: 2, eligible: false, reason: '尚未关联蝦皮商品', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '尚未关联蝦皮商品' }], eligibleCount: 1, skippedCount: 1 }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,8 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
|
||||
await expect(page.getByRole('columnheader', { name: '蝦皮关联' })).toHaveCount(0)
|
||||
await expect(page.getByText('本页不展示采购任务、PDD 订单号、快递单号和支付状态。', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText('未关联', { exact: true }).last()).toBeVisible()
|
||||
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('tbody').getByText('待人工处理', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('26154802795 ↗', { exact: true })).toHaveCount(0)
|
||||
const shopeeTitle = page.locator('.shopee-title').first()
|
||||
await expect(shopeeTitle).toHaveAttribute('title', '用于验证加宽后的虾皮商品标题区域')
|
||||
@@ -65,6 +67,11 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
|
||||
const latest = listURLs.at(-1)
|
||||
expect(latest?.searchParams.get('shopName')).toBe('大碼')
|
||||
expect(latest?.searchParams.get('orderCodes')).toBe('SYB001,SYB002')
|
||||
|
||||
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select').click()
|
||||
await page.getByRole('option', { name: 'PDD 待采集' }).click()
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click()
|
||||
await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending')
|
||||
})
|
||||
|
||||
test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => {
|
||||
|
||||
Reference in New Issue
Block a user