feat(#80): add SYB product process stages

This commit is contained in:
QiuSW
2026-08-25 09:44:02 +08:00
parent af3dd5563b
commit 0794dc580b
9 changed files with 385 additions and 24 deletions
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 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 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
+35 -11
View File
@@ -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
+149
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
+50 -4
View File
@@ -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)
+8 -5
View File
@@ -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
+8 -1
View File
@@ -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 }) => {